42 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
0a4d9512cb feat(ticket-actions): show backend-driven overdue lot warning 2026-07-31 19:38:00 +05:30
faefd34cd4 feat(ticket-actions): show lot context on extension cards 2026-07-31 19:37:20 +05:30
514b42ea91 refactor(ticket): use video detections for assignment map and remove coordinates API 2026-07-31 19:02:25 +05:30
9cd7186357 refactor(ticket): remove defect-classes endpoint and related hooks 2026-07-31 17:25:53 +05:30
87fa04e1b5 feat(extension-requests): add manager queue with quick decisions 2026-07-31 16:33:20 +05:30
2caeae7c91 refactor(ticket): make details lot-based and remove class-detail flow 2026-07-31 12:49:38 +05:30
44d3be5d27 feat(ticket): add audit timeline and safeguard extension rejection 2026-07-31 11:51:32 +05:30
58098af0fe refactor(ticket): make repair review ticket-level 2026-07-30 20:29:49 +05:30
1cbad4074c feat(ticket): show repair review action and hide audit timeline 2026-07-30 17:08:27 +05:30
5e0d641575 fix(ticket): hide details for empty assignment lots 2026-07-30 16:43:16 +05:30
e894676d3b fix(ticket): gate AI detection summary behind assign permission 2026-07-29 19:07:22 +05:30
31839d1c70 fix(ticket): stabilize detection preview index during loading 2026-07-29 18:58:24 +05:30
e8f2955acf feat(ticket): scope issue preview to selected assignment lot 2026-07-29 18:43:22 +05:30
5e66b70611 feat(ticket): scope issue preview to selected assignment lot 2026-07-29 15:44:19 +05:30
7ffe3f0708 feat(ticket): add assignment lots card to ticket detail 2026-07-29 12:16:27 +05:30
8e8803396d feat(ticket): support criteria-based global ticket assignment 2026-07-28 18:47:29 +05:30
0601322361 feat(ticket): add issues map to ticket assignment page 2026-07-28 12:19:38 +05:30
20f611f35a feat(ticket): add global ticket assignment page 2026-07-27 18:31:12 +05:30
224361f144 fix(ui): prevent card borders from being clipped 2026-07-24 15:17:04 +05:30
77b6755d05 refactor(ui): use icon fallback for sidebar user avatar 2026-07-23 10:16:34 +05:30
2f25fd49be fix(ticket): improve ticket detail page scrolling layout 2026-07-22 19:19:50 +05:30
3891500cf2 feat(upload): open upload details dialog from table rows 2026-07-22 19:12:52 +05:30
062e89fcba refactor(ui): update branding assets and clean up repair proof UI 2026-07-22 18:47:00 +05:30
94 changed files with 4837 additions and 1601 deletions

5
public/color_logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

5
public/logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,155 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
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 StatusBadge({ status }: { status: TicketExtensionRequestStatus }) {
const config = statusConfig[status];
return (
<Badge variant="outline" className={cn('border', config.className)}>
{config.label}
</Badge>
);
}
function PersonCell({
name,
role,
}: {
name: string | null | undefined;
role: string | null | undefined;
}) {
return (
<div className="min-w-0">
<p className="truncate font-medium">{name || '-'}</p>
{role ? (
<p className="truncate text-xs text-muted-foreground">{role}</p>
) : null}
</div>
);
}
export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[] {
return useMemo(
() => [
{
accessorKey: 'ticket_name',
header: 'Ticket / Assignment',
size: 170,
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.ticket_name}</p>
<p className="text-xs text-muted-foreground">
Assignment #{row.original.assignment_id}
</p>
</div>
),
},
{
id: 'requested_by',
header: 'Requested By',
size: 170,
meta: { disableTruncate: true },
cell: ({ row }) => (
<PersonCell
name={row.original.requested_by?.name}
role={row.original.requested_by?.role.name}
/>
),
},
{
accessorKey: 'status',
header: 'Status',
size: 125,
meta: { disableTruncate: true },
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: 'current_due_at',
header: 'Current Due Date',
size: 175,
cell: ({ row }) => formatDate(row.original.current_due_at),
},
{
accessorKey: 'requested_due_at',
header: 'Requested Due Date',
size: 175,
cell: ({ row }) => formatDate(row.original.requested_due_at),
},
{
accessorKey: 'reason',
header: 'Request Reason',
size: 260,
},
{
id: 'resolved_by',
header: 'Resolved By',
size: 170,
meta: { disableTruncate: true },
cell: ({ row }) => (
<PersonCell
name={row.original.resolved_by?.name}
role={row.original.resolved_by?.role.name}
/>
),
},
{
accessorKey: 'decision_note',
header: 'Decision Note',
size: 220,
cell: ({ row }) => row.original.decision_note || '-',
},
{
id: 'previous_requests',
header: 'Previous Requests',
size: 150,
cell: ({ row }) => (
<Badge variant="secondary">
{row.original.history_meta.previous_request_count}
</Badge>
),
},
{
accessorKey: 'resolved_at',
header: 'Resolved On',
size: 175,
cell: ({ row }) => formatDate(row.original.resolved_at),
},
],
[],
);
}

View File

@@ -0,0 +1,229 @@
'use client';
import { useCallback, useEffect } from 'react';
import { ArrowRight, Check, Loader2, X } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { TicketExtensionRequest } from '@/types';
import { formatDate } from '@/utils/date';
import {
MAX_EXTENSION_DECISION_NOTE_LENGTH,
type ExtensionRequestDecisionAction,
useExtensionRequestDecision,
} from '../hooks/useExtensionRequestDecision';
interface ExtensionRequestDecisionDialogProps {
request: TicketExtensionRequest | null;
action: ExtensionRequestDecisionAction | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ExtensionRequestDecisionDialog({
request,
action,
open,
onOpenChange,
}: ExtensionRequestDecisionDialogProps) {
const handleSuccess = useCallback(() => onOpenChange(false), [onOpenChange]);
const {
note,
setNote,
noteError,
selectedAction,
isPending,
isRejectConfirmationOpen,
setIsRejectConfirmationOpen,
submitDecision,
requestRejection,
resetDecision,
} = useExtensionRequestDecision({
ticketId: request?.ticket_id ?? '',
assignmentId: request?.assignment_id ?? 0,
onSuccess: handleSuccess,
});
useEffect(() => {
resetDecision();
}, [action, open, request?.id, resetDecision]);
const handleOpenChange = (nextOpen: boolean) => {
if (isPending) return;
onOpenChange(nextOpen);
};
if (!request || !action) return null;
const isApproval = action === 'approve';
const noteErrorId = `extension-manager-note-error-${request.id}`;
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-xl" showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
{isApproval
? 'Approve extension request'
: 'Reject extension request'}
</DialogTitle>
<DialogDescription>
Review {request.ticket_name}, Assignment #{request.assignment_id}{' '}
before confirming this decision.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg border p-3">
<div>
<p className="text-xs text-muted-foreground">
Current Due Date
</p>
<p className="mt-1 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 font-medium">
{formatDate(request.requested_due_at)}
</p>
</div>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Requested By</p>
<p className="mt-1 font-medium">
{request.requested_by?.name ?? 'Assigned worker'}
</p>
<p className="mt-3 text-xs text-muted-foreground">Reason</p>
<p className="mt-1 leading-relaxed">{request.reason}</p>
</div>
<div className="space-y-2">
<div className="space-y-0.5">
<Label htmlFor="extension-manager-note">
Add Manager Comments
</Label>
<p className="text-xs text-muted-foreground">
{isApproval
? 'Optional for approval.'
: 'Required for rejection.'}
</p>
</div>
<div className="relative">
<Textarea
id="extension-manager-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Add a note explaining your decision..."
disabled={isPending}
aria-invalid={Boolean(noteError)}
aria-describedby={noteError ? noteErrorId : undefined}
className="min-h-24 resize-none pb-8"
/>
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
{note.length} / {MAX_EXTENSION_DECISION_NOTE_LENGTH}
</span>
</div>
{noteError ? (
<p id={noteErrorId} className="text-xs text-destructive">
{noteError}
</p>
) : null}
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
{isApproval ? (
<Button
type="button"
disabled={isPending}
onClick={() => void submitDecision('approve')}
>
{selectedAction === 'approve' ? (
<Loader2 className="animate-spin" />
) : (
<Check />
)}
Approve Extension
</Button>
) : (
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={requestRejection}
>
<X />
Reject Request
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={isRejectConfirmationOpen}
onOpenChange={setIsRejectConfirmationOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reject the extension request for{' '}
{request.ticket_name}, Assignment #{request.assignment_id}? This
decision cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isPending}
onClick={() => void submitDecision('reject')}
>
{selectedAction === 'reject' ? (
<Loader2 className="animate-spin" />
) : null}
Yes, Reject
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { FilterX } from 'lucide-react';
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ExtensionRequestStatusFilter } from '../hooks/useExtensionRequestFilters';
interface ExtensionRequestFiltersProps {
ticketName: string;
statusFilter: ExtensionRequestStatusFilter;
onTicketNameChange: (value: string) => void;
onStatusChange: (value: ExtensionRequestStatusFilter) => void;
onClear: () => void;
}
export function ExtensionRequestFilters({
ticketName,
statusFilter,
onTicketNameChange,
onStatusChange,
onClear,
}: ExtensionRequestFiltersProps) {
return (
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
<TableSearchInput
value={ticketName}
onChange={onTicketNameChange}
placeholder="Search by ticket name"
className="w-full sm:max-w-sm"
/>
<Select
value={statusFilter}
onValueChange={(value) =>
onStatusChange(value as ExtensionRequestStatusFilter)
}
>
<SelectTrigger className="w-full sm:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!ticketName && statusFilter === 'pending'}
onClick={onClear}
>
<FilterX />
Clear Filters
</Button>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable, type DataTableAction } from '@/components/data-table';
import type { TicketExtensionRequest } from '@/types';
interface ExtensionRequestTableProps {
columns: ColumnDef<TicketExtensionRequest>[];
requests: TicketExtensionRequest[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onView: (request: TicketExtensionRequest) => void;
actions: DataTableAction<TicketExtensionRequest>[];
}
export function ExtensionRequestTable({
columns,
requests,
isLoading,
toolbar,
skip,
limit,
total,
onPageChange,
onLimitChange,
onView,
actions,
}: ExtensionRequestTableProps) {
return (
<DataTable
title="Extension Requests"
columns={columns}
data={requests}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No extension requests found."
emptyDescription="Try changing the ticket or status filters."
onRowClick={onView}
actions={actions}
actionsSticky
minColumnSize={130}
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
/>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useCallback, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ticketService } from '@/services/api';
import type { ReviewTicketExtensionPayload } from '@/types';
import { ticketKeys } from '../../ticket/queries/ticketKeys';
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
export const MAX_EXTENSION_DECISION_NOTE_LENGTH = 300;
export type ExtensionRequestDecisionAction = 'approve' | 'reject';
interface UseExtensionRequestDecisionOptions {
ticketId: string;
assignmentId: number;
onSuccess?: () => void;
}
export function useExtensionRequestDecision({
ticketId,
assignmentId,
onSuccess,
}: UseExtensionRequestDecisionOptions) {
const queryClient = useQueryClient();
const [note, setNoteValue] = useState('');
const [noteError, setNoteError] = useState<string | null>(null);
const [selectedAction, setSelectedAction] =
useState<ExtensionRequestDecisionAction | null>(null);
const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
useState(false);
const reviewMutation = useMutation({
mutationFn: (payload: ReviewTicketExtensionPayload) =>
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
onSuccess: (_response, payload) => {
toast.success(
payload.action === 'approve'
? 'Extension request approved'
: 'Extension request rejected',
);
queryClient.invalidateQueries({
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
queryClient.invalidateQueries({
queryKey: extensionRequestKeys.lists(),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
onSuccess?.();
},
onError: () => toast.error('Failed to review extension request'),
});
const setNote = useCallback((value: string) => {
const nextNote = value.slice(0, MAX_EXTENSION_DECISION_NOTE_LENGTH);
setNoteValue(nextNote);
if (nextNote.trim()) setNoteError(null);
}, []);
const submitDecision = useCallback(
async (action: ExtensionRequestDecisionAction) => {
const trimmedNote = note.trim();
if (action === 'reject' && !trimmedNote) {
setNoteError('Manager comments are required to reject this request.');
setIsRejectConfirmationOpen(false);
return;
}
setSelectedAction(action);
try {
await reviewMutation.mutateAsync({
action,
...(trimmedNote ? { note: trimmedNote } : {}),
});
} catch {
// The mutation displays the error toast and leaves the form open.
} finally {
setSelectedAction(null);
}
},
[note, reviewMutation],
);
const requestRejection = useCallback(() => {
if (!note.trim()) {
setNoteError('Manager comments are required to reject this request.');
return;
}
setNoteError(null);
setIsRejectConfirmationOpen(true);
}, [note]);
const resetDecision = useCallback(() => {
setNoteValue('');
setNoteError(null);
setSelectedAction(null);
setIsRejectConfirmationOpen(false);
}, []);
return {
note,
setNote,
noteError,
selectedAction,
isPending: reviewMutation.isPending,
isRejectConfirmationOpen,
setIsRejectConfirmationOpen,
submitDecision,
requestRejection,
resetDecision,
};
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useCallback, useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import type { TicketExtensionRequestStatus } from '@/types';
export type ExtensionRequestStatusFilter = 'all' | TicketExtensionRequestStatus;
export function useExtensionRequestFilters() {
const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10);
const [ticketName, setTicketNameValue] = useState('');
const [statusFilter, setStatusFilterValue] =
useState<ExtensionRequestStatusFilter>('pending');
const debouncedTicketName = useDebounce(ticketName.trim(), 400);
const setLimit = useCallback((value: number) => {
setLimitValue(value);
setSkip(0);
}, []);
const setTicketName = useCallback((value: string) => {
setTicketNameValue(value);
setSkip(0);
}, []);
const setStatusFilter = useCallback((value: ExtensionRequestStatusFilter) => {
setStatusFilterValue(value);
setSkip(0);
}, []);
const clearFilters = useCallback(() => {
setTicketNameValue('');
setStatusFilterValue('pending');
setSkip(0);
}, []);
return {
skip,
setSkip,
limit,
setLimit,
ticketName,
debouncedTicketName,
setTicketName,
statusFilter,
setStatusFilter,
clearFilters,
};
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ticketService } from '@/services/api';
import type { TicketExtensionRequestsParams } from '@/types';
import type { ExtensionRequestStatusFilter } from './useExtensionRequestFilters';
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
interface UseExtensionRequestsQueryParams {
skip: number;
limit: number;
ticketName: string;
statusFilter: ExtensionRequestStatusFilter;
}
function buildExtensionRequestParams({
skip,
limit,
ticketName,
statusFilter,
}: UseExtensionRequestsQueryParams): TicketExtensionRequestsParams {
return {
skip,
limit,
ticket_name: ticketName || undefined,
status: statusFilter === 'all' ? undefined : statusFilter,
};
}
export function useExtensionRequestsQuery(
params: UseExtensionRequestsQueryParams,
) {
const { skip, limit, ticketName, statusFilter } = params;
const queryParams = useMemo(
() =>
buildExtensionRequestParams({ skip, limit, ticketName, statusFilter }),
[limit, skip, statusFilter, ticketName],
);
return useQuery({
queryKey: extensionRequestKeys.list(queryParams),
queryFn: () => ticketService.getTicketExtensionRequests(queryParams),
});
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function ExtensionRequestsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.TICKET.ASSIGN}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { AlertCircle, CalendarClock, Check, RotateCcw, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { DataTableAction } from '@/components/data-table';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import type { TicketExtensionRequest } from '@/types';
import { ROUTES } from '@/utils/routes';
import { useExtensionRequestColumns } from './components/ExtensionRequestColumns';
import { ExtensionRequestDecisionDialog } from './components/ExtensionRequestDecisionDialog';
import { ExtensionRequestFilters } from './components/ExtensionRequestFilters';
import { ExtensionRequestTable } from './components/ExtensionRequestTable';
import type { ExtensionRequestDecisionAction } from './hooks/useExtensionRequestDecision';
import { useExtensionRequestFilters } from './hooks/useExtensionRequestFilters';
import { useExtensionRequestsQuery } from './hooks/useExtensionRequestQueries';
export default function ExtensionRequestsPage() {
const router = useRouter();
const [decision, setDecision] = useState<{
request: TicketExtensionRequest;
action: ExtensionRequestDecisionAction;
} | null>(null);
const {
skip,
setSkip,
limit,
setLimit,
ticketName,
debouncedTicketName,
setTicketName,
statusFilter,
setStatusFilter,
clearFilters,
} = useExtensionRequestFilters();
const extensionRequestsQuery = useExtensionRequestsQuery({
skip,
limit,
ticketName: debouncedTicketName,
statusFilter,
});
const columns = useExtensionRequestColumns();
const requests = extensionRequestsQuery.data?.items ?? [];
const total = extensionRequestsQuery.data?.total ?? 0;
const actions = useMemo<DataTableAction<TicketExtensionRequest>[]>(
() => [
{
label: 'Approve extension',
icon: <Check className="text-emerald-600" />,
permission: PERMISSIONS.TICKET.ASSIGN,
hidden: (request) => request.status !== 'pending',
className:
'border-emerald-200 hover:bg-emerald-50 dark:border-emerald-900 dark:hover:bg-emerald-950/50',
onClick: (request) => setDecision({ request, action: 'approve' }),
},
{
label: 'Reject extension',
icon: <X className="text-destructive" />,
permission: PERMISSIONS.TICKET.ASSIGN,
hidden: (request) => request.status !== 'pending',
className:
'border-destructive/30 hover:bg-destructive/5 hover:text-destructive',
onClick: (request) => setDecision({ request, action: 'reject' }),
},
],
[],
);
const openTicket = useCallback(
(request: TicketExtensionRequest) => {
router.push(ROUTES.TICKET_DETAIL(request.ticket_id));
},
[router],
);
const filterToolbar = useMemo(
() => (
<ExtensionRequestFilters
ticketName={ticketName}
statusFilter={statusFilter}
onTicketNameChange={setTicketName}
onStatusChange={setStatusFilter}
onClear={clearFilters}
/>
),
[clearFilters, setTicketName, setStatusFilter, statusFilter, ticketName],
);
return (
<main className="relative z-10 space-y-5">
<PageHeader
title="Extension Requests"
description="Track deadline extension requests across all ticket assignments."
icon={CalendarClock}
/>
{extensionRequestsQuery.isError ? (
<div
role="alert"
className="flex min-h-48 flex-col items-center justify-center rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 text-center"
>
<AlertCircle className="mb-2 size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load extension requests.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
disabled={extensionRequestsQuery.isFetching}
onClick={() => extensionRequestsQuery.refetch()}
>
<RotateCcw
className={
extensionRequestsQuery.isFetching ? 'animate-spin' : undefined
}
/>
Retry
</Button>
</div>
) : (
<ExtensionRequestTable
columns={columns}
requests={requests}
isLoading={extensionRequestsQuery.isLoading}
toolbar={filterToolbar}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onView={openTicket}
actions={actions}
/>
)}
<ExtensionRequestDecisionDialog
request={decision?.request ?? null}
action={decision?.action ?? null}
open={Boolean(decision)}
onOpenChange={(open) => {
if (!open) setDecision(null);
}}
/>
</main>
);
}

View File

@@ -0,0 +1,8 @@
import type { TicketExtensionRequestsParams } from '@/types';
export const extensionRequestKeys = {
all: ['extension-requests'] as const,
lists: () => [...extensionRequestKeys.all, 'list'] as const,
list: (params: TicketExtensionRequestsParams) =>
[...extensionRequestKeys.lists(), params] as const,
};

View File

@@ -11,13 +11,13 @@ const ModulesLayout = ({
}>) => { }>) => {
return ( return (
<AuthGuard> <AuthGuard>
<SidebarProvider> <SidebarProvider defaultOpen={false}>
<AppSidebar /> <AppSidebar />
<main className="flex flex-1 h-screen w-full flex-col overflow-hidden"> <main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
<div className="scroll-stable flex-1 overflow-auto"> <div className="scroll-stable min-w-0 flex-1 overflow-auto">
<div className="mx-auto flex min-h-full w-full max-w-380 flex-col"> <div className="mx-auto flex min-h-full w-full min-w-0 max-w-380 flex-col">
<ModuleShellHeader /> <ModuleShellHeader />
<div className="flex-1 p-6">{children}</div> <div className="min-w-0 flex-1 p-6">{children}</div>
</div> </div>
</div> </div>
</main> </main>

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

@@ -0,0 +1,242 @@
'use client';
import { useMemo, useState } from 'react';
import {
ArrowRight,
Mail,
MapPin,
Phone,
Send,
ShieldCheck,
Users,
} from 'lucide-react';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { AssignableTicketUser } from '@/types';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import {
formatCompactRangeCoordinate,
formatRangeCoordinate,
type AssignmentRangePoint,
} from './assignmentRange';
interface AssignTicketFormProps {
ticketId: string;
selectedClassNames: string[];
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onAssigned: () => void;
}
export function AssignTicketForm({
ticketId,
selectedClassNames,
startPoint,
endPoint,
onAssigned,
}: AssignTicketFormProps) {
const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticketId);
const today = useMemo(() => {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}, []);
const maxDueMonth = useMemo(
() => new Date(today.getFullYear() + 20, 11),
[today],
);
const hasIncompleteLocationRange = Boolean(startPoint) !== Boolean(endPoint);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="text-primary" />
Contractor and schedule
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-5">
<div className="space-y-2">
<Label>
Contractor <span className="text-destructive">*</span>
</Label>
<AssignableWorkerSelect
value={selectedUserId}
onValueChange={(userId, user) => {
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending}
/>
</div>
{selectedContractor ? (
<div className="space-y-2 rounded-lg border bg-muted/30 p-4">
<p className="text-sm font-medium">Contractor details</p>
{selectedContractor.phone_number ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="size-4 shrink-0" />
{selectedContractor.phone_number}
</p>
) : null}
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="size-4 shrink-0" />
<span className="truncate">{selectedContractor.email}</span>
</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
{selectedContractor.role.name}
</p>
</div>
) : null}
{startPoint || endPoint ? (
<div className="space-y-2">
<Label>Location</Label>
<div
className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2 text-xs"
title={[
startPoint ? formatRangeCoordinate(startPoint) : null,
endPoint ? formatRangeCoordinate(endPoint) : null,
]
.filter(Boolean)
.join(' to ')}
>
<MapPin className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 font-mono">
{startPoint
? formatCompactRangeCoordinate(startPoint)
: endPoint
? formatCompactRangeCoordinate(endPoint)
: null}
</span>
{startPoint && endPoint ? (
<>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 font-mono">
{formatCompactRangeCoordinate(endPoint)}
</span>
</>
) : null}
</div>
{hasIncompleteLocationRange ? (
<p className="text-xs text-destructive">
{startPoint
? 'Select an end location to complete the range.'
: 'Select a start location to complete the range.'}
</p>
) : null}
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="assign-due-date">
Due date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="assign-due-date"
value={dueDate}
onChange={setDueDate}
showLabel={false}
placeholder="Select due date"
disabled={assignMutation.isPending}
className="w-full"
startMonth={today}
endMonth={maxDueMonth}
disabledDates={{ before: today }}
/>
</div>
<div className="space-y-2">
<Label htmlFor="assign-note">Assignment note (optional)</Label>
<Textarea
id="assign-note"
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Add instructions or notes for the contractor..."
disabled={assignMutation.isPending}
/>
</div>
<Button
type="button"
disabled={
!selectedUserId ||
!selectedContractor ||
!dueDate ||
hasIncompleteLocationRange ||
assignMutation.isPending
}
onClick={() => {
if (
!selectedUserId ||
!selectedContractor ||
!dueDate ||
hasIncompleteLocationRange
) {
return;
}
const note = assignNote.trim() || undefined;
const hasCriteria =
selectedClassNames.length > 0 ||
Boolean(startPoint && endPoint);
assignMutation.mutate(
{
assigned_to_user_id: Number(selectedUserId),
assigned_to_email: selectedContractor.email,
due_at: dueDate.toISOString(),
note,
...(hasCriteria
? {
criteria: {
...(selectedClassNames.length > 0
? { class_names: selectedClassNames }
: {}),
...(startPoint && endPoint
? {
start_lat: startPoint.latitude,
start_lng: startPoint.longitude,
end_lat: endPoint.latitude,
end_lng: endPoint.longitude,
}
: {}),
},
}
: {}),
},
{
onSuccess: () => {
setSelectedUserId('');
setSelectedContractor(null);
setDueDate(undefined);
setAssignNote('');
onAssigned();
},
},
);
}}
className="w-full sm:w-auto"
>
<Send />
{assignMutation.isPending ? 'Assigning...' : 'Assign ticket'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,475 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type {
TicketAssignmentDetectionItem,
TicketAssignmentDetectionsResponse,
} from '@/types';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { AssignmentRangePoint } from './assignmentRange';
type AssignmentIssuesMapProps = {
data?: TicketAssignmentDetectionsResponse;
isLoading: boolean;
isError: boolean;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onRetry: () => void;
};
type AssignmentMapItem = {
id: number;
class_name: string;
display_name: string;
latitude: number;
longitude: number;
sequence: number;
};
type AssignmentMapClass = {
class_name: string;
display_name: string;
};
type ClientAssignmentIssuesMapProps = {
items: AssignmentMapItem[];
classes: AssignmentMapClass[];
selectedDetectionId: number | null;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSelectDetection: (detectionId: number) => void;
};
function toMapItem(
item: TicketAssignmentDetectionItem,
sequence: number,
): AssignmentMapItem | null {
const { latitude, longitude } = item.location;
if (
typeof latitude !== 'number' ||
typeof longitude !== 'number' ||
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
return null;
}
return {
id: item.detection.id,
class_name: item.detection.class_name,
display_name: item.detection.display_name,
latitude,
longitude,
sequence,
};
}
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
async () => {
const {
CircleMarker,
MapContainer,
Polyline,
Popup,
TileLayer,
Tooltip,
useMap,
} = await import('react-leaflet');
const { canvas } = await import('leaflet');
function FitMapToIssues({ items }: { items: AssignmentMapItem[] }) {
const map = useMap();
useEffect(() => {
if (items.length === 0) return;
if (items.length === 1) {
map.setView([items[0].latitude, items[0].longitude], 17);
return;
}
map.fitBounds(
items.map(
(item) => [item.latitude, item.longitude] as [number, number],
),
{
padding: [32, 32],
maxZoom: 17,
},
);
}, [items, map]);
return null;
}
return function ClientAssignmentIssuesMapInner({
items,
classes,
selectedDetectionId,
startPoint,
endPoint,
onSelectDetection,
}: ClientAssignmentIssuesMapProps) {
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
const displayNames = new Map(
classes.map((item) => [item.class_name, item.display_name]),
);
const initialCenter: [number, number] = [
items[0].latitude,
items[0].longitude,
];
const orderedItems = [...items].sort(
(first, second) =>
first.sequence - second.sequence || first.id - second.id,
);
const startIndex = orderedItems.findIndex(
(item) => item.id === startPoint?.detectionId,
);
const endIndex = orderedItems.findIndex(
(item) => item.id === endPoint?.detectionId,
);
const hasSelectedRange = startIndex >= 0 && endIndex >= 0;
const rangeItems = hasSelectedRange
? orderedItems.slice(
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex) + 1,
)
: [];
const rangeDetectionIds = new Set(rangeItems.map((item) => item.id));
const rangePath = rangeItems.map(
(item) => [item.latitude, item.longitude] as [number, number],
);
return (
<MapContainer
center={initialCenter}
zoom={15}
scrollWheelZoom
className="h-full w-full"
>
<TileLayer
attribution="&copy; OpenStreetMap contributors"
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitMapToIssues items={hasSelectedRange ? rangeItems : items} />
{rangePath.length >= 2 ? (
<>
<Polyline
positions={rangePath}
interactive={false}
pathOptions={{
color: '#3b82f6',
opacity: 0.18,
weight: 26,
}}
/>
<Polyline
positions={rangePath}
interactive={false}
pathOptions={{
color: '#2563eb',
opacity: 0.9,
weight: 4,
}}
/>
</>
) : null}
{items.map((item) => {
const visual = getDefectVisual(item.class_name);
const IssueIcon = visual.icon;
const isSelected = selectedDetectionId === item.id;
const isStart = startPoint?.detectionId === item.id;
const isEnd = endPoint?.detectionId === item.id;
const rangeLabel = isStart ? 'Start' : isEnd ? 'End' : null;
const rangeColor = isStart
? '#16a34a'
: isEnd
? '#dc2626'
: visual.boundingBoxColor;
const isRangeIssue = isStart || isEnd;
const isInsideRange = rangeDetectionIds.has(item.id);
const displayName =
displayNames.get(item.class_name) ??
item.display_name ??
item.class_name.replaceAll('_', ' ');
return (
<CircleMarker
key={item.id}
center={[item.latitude, item.longitude]}
renderer={markerRenderer}
radius={isSelected || isRangeIssue ? 11 : isInsideRange ? 9 : 7}
pathOptions={{
color:
isSelected || isRangeIssue
? '#ffffff'
: visual.boundingBoxColor,
fillColor: rangeColor,
fillOpacity:
isSelected || isRangeIssue
? 1
: hasSelectedRange && !isInsideRange
? 0.22
: 0.82,
opacity: hasSelectedRange && !isInsideRange ? 0.3 : 1,
weight: isSelected || isRangeIssue ? 4 : 2,
}}
eventHandlers={{
click: () => onSelectDetection(item.id),
}}
>
<Popup minWidth={240} maxWidth={280}>
<div className="w-60">
<div className="flex items-start gap-3 border-b pb-3 pr-4">
<span
className="flex size-9 shrink-0 items-center justify-center rounded-full"
style={{
backgroundColor: `${visual.boundingBoxColor}1f`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="!m-0 truncate text-sm font-semibold leading-5">
{displayName}
</p>
<p className="!m-0 text-xs leading-4 text-muted-foreground">
Detection #{item.id}
</p>
</div>
{rangeLabel ? (
<span className="flex shrink-0 items-center gap-1.5 text-xs font-semibold">
<span
className="size-3 rounded-full"
style={{ backgroundColor: rangeColor }}
/>
{rangeLabel}
</span>
) : null}
</div>
<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">Latitude</dt>
<dd className="!m-0 font-mono text-xs tabular-nums">
{item.latitude.toFixed(6)}
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Longitude</dt>
<dd className="!m-0 font-mono text-xs tabular-nums">
{item.longitude.toFixed(6)}
</dd>
</div>
</dl>
</div>
</Popup>
{rangeLabel ? (
<Tooltip
permanent
direction="top"
offset={[0, -10]}
opacity={1}
className="range-point-label"
>
<span style={{ color: rangeColor }}>{rangeLabel}</span>
</Tooltip>
) : null}
</CircleMarker>
);
})}
</MapContainer>
);
};
},
{
ssr: false,
loading: () => <div className="size-full animate-pulse bg-muted" />,
},
);
export function AssignmentIssuesMap({
data,
isLoading,
isError,
startPoint,
endPoint,
onRetry,
}: AssignmentIssuesMapProps) {
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
null,
);
const validItems = useMemo(
() =>
(data?.items ?? []).flatMap((item, index) => {
const mapItem = toMapItem(item, index);
return mapItem ? [mapItem] : [];
}),
[data?.items],
);
const classes = useMemo(
() =>
Array.from(
new Map(
validItems.map((item) => [
item.class_name,
{
class_name: item.class_name,
display_name: item.display_name,
},
]),
).values(),
),
[validItems],
);
const pageItemCount = data?.items.length ?? 0;
useEffect(() => {
if (
selectedDetectionId !== null &&
!validItems.some((item) => item.id === selectedDetectionId)
) {
setSelectedDetectionId(null);
}
}, [selectedDetectionId, validItems]);
return (
<Card className="overflow-hidden">
<CardHeader className="border-b">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-secondary p-2">
<MapPinned className="size-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">Issues on Map</CardTitle>
<CardDescription>
Select Start and End in the table to preview the covered range.
</CardDescription>
</div>
</div>
{data ? (
<span className="shrink-0 text-sm text-muted-foreground">
{validItems.length} mapped
</span>
) : null}
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="h-[440px] animate-pulse bg-muted" />
) : isError ? (
<div className="flex min-h-72 flex-col items-center justify-center gap-3 px-6 text-center">
<AlertTriangle className="size-7 text-destructive" />
<div>
<p className="font-medium">Unable to load issue locations.</p>
<p className="mt-1 text-sm text-muted-foreground">
Check the connection and try again.
</p>
</div>
<Button type="button" variant="outline" onClick={onRetry}>
<RefreshCw />
Retry
</Button>
</div>
) : validItems.length === 0 ? (
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
<MapPinned className="size-7 text-muted-foreground" />
<p className="mt-3 font-medium">No issue locations available.</p>
<p className="mt-1 text-sm text-muted-foreground">
Detections without valid coordinates cannot be placed on the map.
</p>
</div>
) : (
<>
<div className="h-[440px] min-w-0 bg-muted">
<ClientAssignmentIssuesMap
items={validItems}
classes={classes}
selectedDetectionId={selectedDetectionId}
startPoint={startPoint}
endPoint={endPoint}
onSelectDetection={setSelectedDetectionId}
/>
</div>
<div
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-3"
aria-label="Map legend"
>
<span className="text-sm font-medium">Map legend</span>
{classes.map((item) => {
const visual = getDefectVisual(item.class_name);
const IssueIcon = visual.icon;
return (
<div
key={item.class_name}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<span
className="flex size-6 items-center justify-center rounded-full"
style={{
backgroundColor: `${visual.boundingBoxColor}1f`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon className="size-3.5" />
</span>
<span>{item.display_name}</span>
</div>
);
})}
{startPoint ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="size-4 rounded-full border-2 border-white bg-green-600 shadow-sm" />
<span>Start</span>
</div>
) : null}
{endPoint ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="size-4 rounded-full border-2 border-white bg-red-600 shadow-sm" />
<span>End</span>
</div>
) : null}
{startPoint && endPoint ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative h-4 w-8 rounded-full bg-blue-500/20">
<span className="absolute top-1/2 right-0 left-0 h-0.5 -translate-y-1/2 bg-blue-600" />
</span>
<span>Coverage area</span>
</div>
) : null}
</div>
<div className="border-t px-4 py-3">
<span className="text-sm text-muted-foreground">
{validItems.length} mapped from {pageItemCount} cached{' '}
{pageItemCount === 1 ? 'issue' : 'issues'}
</span>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AssignmentRangePoint } from './assignmentRange';
interface AssignmentRangeActionsProps {
point: AssignmentRangePoint;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSetStart: (point: AssignmentRangePoint) => void;
onSetEnd: (point: AssignmentRangePoint) => void;
stretch?: boolean;
className?: string;
}
export function AssignmentRangeActions({
point,
startPoint,
endPoint,
onSetStart,
onSetEnd,
stretch = false,
className,
}: AssignmentRangeActionsProps) {
const isStart = point.detectionId === startPoint?.detectionId;
const isEnd = point.detectionId === endPoint?.detectionId;
return (
<div className={cn('flex items-center gap-1', className)}>
<Button
type="button"
size="xs"
variant={isStart ? 'secondary' : 'outline'}
className={cn(stretch && 'flex-1')}
disabled={isStart}
aria-pressed={isStart}
title={isStart ? 'Selected as start' : 'Set as start'}
onClick={() => onSetStart(point)}
>
Start
</Button>
<Button
type="button"
size="xs"
variant={isEnd ? 'secondary' : 'outline'}
className={cn(stretch && 'flex-1')}
disabled={startPoint === null || isStart || isEnd}
aria-pressed={isEnd}
title={
startPoint === null
? 'Select a start issue first'
: isStart
? 'Choose another issue for the end'
: isEnd
? 'Selected as end'
: 'Set as end'
}
onClick={() => onSetEnd(point)}
>
End
</Button>
</div>
);
}

View File

@@ -0,0 +1,232 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FilterX } from 'lucide-react';
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketAssignmentDetectionsResponse } from '@/types';
import { useTicketAssignmentDetectionsQuery } from '../../../hooks/useTicketQueries';
import {
formatCompactRangeCoordinate,
formatRangeCoordinate,
type AssignmentRangePoint,
} from './assignmentRange';
import { AssignmentIssuesMap } from './AssignmentIssuesMap';
import { useIssueColumns } from './IssueColumns';
import { IssueTable } from './IssueTable';
const DEFAULT_PAGE_SIZE = 10;
interface IssueTypeOption {
class_name: string;
display_name: string;
count: number;
}
interface ChooseIssuesSectionProps {
ticketId: string;
issueTypes: IssueTypeOption[];
selectedIssueTypes: string[];
cacheRevision: number;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSelectedIssueTypesChange: (values: string[]) => void;
onStartPointChange: (point: AssignmentRangePoint | null) => void;
onEndPointChange: (point: AssignmentRangePoint | null) => void;
}
export function ChooseIssuesSection({
ticketId,
issueTypes,
selectedIssueTypes,
cacheRevision,
startPoint,
endPoint,
onSelectedIssueTypesChange,
onStartPointChange,
onEndPointChange,
}: ChooseIssuesSectionProps) {
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(DEFAULT_PAGE_SIZE);
const handleSetStart = useCallback(
(point: AssignmentRangePoint) => {
onStartPointChange(point);
onEndPointChange(null);
},
[onEndPointChange, onStartPointChange],
);
const handleSetEnd = useCallback(
(point: AssignmentRangePoint) => {
if (point.detectionId === startPoint?.detectionId) return;
onEndPointChange(point);
},
[onEndPointChange, startPoint?.detectionId],
);
const columns = useIssueColumns({
startPoint,
endPoint,
onSetStart: handleSetStart,
onSetEnd: handleSetEnd,
});
const issueTypeOptions = useMemo(
() =>
issueTypes.map((issue) => ({
value: issue.class_name,
label: issue.display_name,
})),
[issueTypes],
);
const handleIssueTypesChange = useCallback(
(values: string[]) => {
setSkip(0);
onSelectedIssueTypesChange(values);
onStartPointChange(null);
onEndPointChange(null);
},
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
);
const hasActiveFilters =
selectedIssueTypes.length > 0 || startPoint !== null || endPoint !== null;
const clearFilters = useCallback(() => {
setSkip(0);
onSelectedIssueTypesChange([]);
onStartPointChange(null);
onEndPointChange(null);
}, [onEndPointChange, onSelectedIssueTypesChange, onStartPointChange]);
const queryParams = useMemo(
() => ({
assignment_status: 'unassigned' as const,
skip,
limit,
class_name:
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
}),
[limit, selectedIssueTypes, skip],
);
const detectionsQuery = useTicketAssignmentDetectionsQuery(
ticketId,
queryParams,
);
const result = detectionsQuery.data;
const mapCacheKey = useMemo(
() => JSON.stringify([ticketId, selectedIssueTypes, cacheRevision]),
[cacheRevision, selectedIssueTypes, ticketId],
);
const [mapCache, setMapCache] = useState<
Record<string, TicketAssignmentDetectionsResponse>
>({});
useEffect(() => {
if (!result) return;
setMapCache((current) => {
const itemsById = new Map(
(current[mapCacheKey]?.items ?? []).map((item) => [
item.detection.id,
item,
]),
);
result.items.forEach((item) => itemsById.set(item.detection.id, item));
return {
...current,
[mapCacheKey]: {
...result,
skip: 0,
limit: itemsById.size,
items: Array.from(itemsById.values()),
},
};
});
}, [mapCacheKey, result]);
const mapData = mapCache[mapCacheKey];
return (
<div className="min-w-0 space-y-5">
<Card size="sm" aria-label="Issue filters">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle>Filters</CardTitle>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!hasActiveFilters}
onClick={clearFilters}
>
<FilterX />
Clear Filters
</Button>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2">
<MultiSelectPopover
label="Issue Types"
options={issueTypeOptions}
values={selectedIssueTypes}
onValuesChange={handleIssueTypesChange}
searchPlaceholder="Search issue types"
emptyMessage="No issue types found."
emptySelectionLabel="All"
align="start"
/>
{startPoint ? (
<Badge
variant="outline"
className="h-8 gap-1.5 px-2.5 font-normal"
title={formatRangeCoordinate(startPoint)}
>
<span className="font-medium">Start</span>
<span className="font-mono">
{formatCompactRangeCoordinate(startPoint)}
</span>
</Badge>
) : null}
{endPoint ? (
<Badge
variant="outline"
className="h-8 gap-1.5 px-2.5 font-normal"
title={formatRangeCoordinate(endPoint)}
>
<span className="font-medium">End</span>
<span className="font-mono">
{formatCompactRangeCoordinate(endPoint)}
</span>
</Badge>
) : null}
</div>
</CardContent>
</Card>
<IssueTable
columns={columns}
issues={result?.items ?? []}
isLoading={detectionsQuery.isLoading}
isError={detectionsQuery.isError}
toolbar={null}
skip={skip}
limit={limit}
total={result?.total ?? 0}
onPageChange={setSkip}
onLimitChange={setLimit}
/>
<AssignmentIssuesMap
data={mapData}
isLoading={detectionsQuery.isLoading && !mapData}
isError={detectionsQuery.isError && !mapData}
startPoint={startPoint}
endPoint={endPoint}
onRetry={() => void detectionsQuery.refetch()}
/>
</div>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { TicketAssignmentDetectionItem } from '@/types';
import type { AssignmentRangePoint } from './assignmentRange';
import { AssignmentRangeActions } from './AssignmentRangeActions';
interface UseIssueColumnsOptions {
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSetStart: (point: AssignmentRangePoint) => void;
onSetEnd: (point: AssignmentRangePoint) => void;
}
export function useIssueColumns({
startPoint,
endPoint,
onSetStart,
onSetEnd,
}: UseIssueColumnsOptions): ColumnDef<TicketAssignmentDetectionItem>[] {
return useMemo(
() => [
{
id: 'detection_id',
header: 'Detection ID',
size: 140,
cell: ({ row }) => `#${row.original.detection.id}`,
},
{
id: 'issue_type',
header: 'Issue Type',
size: 180,
cell: ({ row }) => {
const { class_name: className, display_name: displayName } =
row.original.detection;
const visual = getDefectVisual(className);
const IssueIcon = visual.icon;
return (
<Badge
variant="outline"
className="h-5 gap-1.5 px-2 font-normal"
style={{
borderColor: `${visual.boundingBoxColor}66`,
backgroundColor: `${visual.boundingBoxColor}14`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon />
{displayName}
</Badge>
);
},
},
{
id: 'coordinates',
header: 'Coordinates',
size: 220,
cell: ({ row }) => {
const issueId = row.original.detection.id;
const { latitude, longitude } = row.original.location;
if (latitude === null || longitude === null) {
return <span className="text-muted-foreground">Unavailable</span>;
}
return (
<div>
<p className="font-mono text-xs">
{latitude.toFixed(6)}, {longitude.toFixed(6)}
</p>
{issueId === startPoint?.detectionId ? (
<p className="text-xs font-medium text-green-600">Start</p>
) : issueId === endPoint?.detectionId ? (
<p className="text-xs font-medium text-red-600">End</p>
) : null}
</div>
);
},
},
{
id: 'range_actions',
header: 'Set range',
size: 150,
cell: ({ row }) => {
const detectionId = row.original.detection.id;
const { latitude, longitude } = row.original.location;
const hasValidCoordinate =
latitude !== null &&
longitude !== null &&
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180;
if (!hasValidCoordinate) {
return <span className="text-muted-foreground">Unavailable</span>;
}
const point: AssignmentRangePoint = {
detectionId,
latitude,
longitude,
};
return (
<AssignmentRangeActions
point={point}
startPoint={startPoint}
endPoint={endPoint}
onSetStart={onSetStart}
onSetEnd={onSetEnd}
/>
);
},
},
],
[endPoint, onSetEnd, onSetStart, startPoint],
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { TicketAssignmentDetectionItem } from '@/types';
interface IssueTableProps {
columns: ColumnDef<TicketAssignmentDetectionItem>[];
issues: TicketAssignmentDetectionItem[];
isLoading: boolean;
isError: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
}
export function IssueTable({
columns,
issues,
isLoading,
isError,
toolbar,
skip,
limit,
total,
onPageChange,
onLimitChange,
}: IssueTableProps) {
return (
<DataTable
title="Issue details"
columns={columns}
data={issues}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle={isError ? 'Failed to load issues.' : 'No issues found.'}
emptyDescription={
isError
? 'Please try again.'
: 'No detections match the current filters.'
}
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
/>
);
}

View File

@@ -0,0 +1,13 @@
export interface AssignmentRangePoint {
detectionId: number;
latitude: number;
longitude: number;
}
export function formatRangeCoordinate(point: AssignmentRangePoint) {
return `${point.latitude.toFixed(6)}, ${point.longitude.toFixed(6)}`;
}
export function formatCompactRangeCoordinate(point: AssignmentRangePoint) {
return `${point.latitude.toFixed(4)}, ${point.longitude.toFixed(4)}`;
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { ROUTES } from '@/utils/routes';
import { useTicketOverviewQuery } from '../../hooks/useTicketQueries';
import type { AssignmentRangePoint } from './components/assignmentRange';
import { AssignTicketForm } from './components/AssignTicketForm';
import { ChooseIssuesSection } from './components/ChooseIssuesSection';
function AssignmentPageSkeleton() {
return (
<div className="space-y-5" aria-label="Loading assignment workspace">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-4 w-40" />
</div>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<Skeleton className="h-[540px] rounded-xl" />
<Skeleton className="h-[430px] rounded-xl" />
</div>
</div>
);
}
export default function TicketAssignmentPage() {
const router = useRouter();
const { ticketId } = useParams() as { ticketId: string };
const [startPoint, setStartPoint] = useState<AssignmentRangePoint | null>(
null,
);
const [endPoint, setEndPoint] = useState<AssignmentRangePoint | null>(null);
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
const [assignmentRevision, setAssignmentRevision] = useState(0);
const overviewQuery = useTicketOverviewQuery(ticketId);
const overview = overviewQuery.data;
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
const backToTicket = () => router.push(ticketDetailUrl);
if (overviewQuery.isLoading) {
return <AssignmentPageSkeleton />;
}
if (overviewQuery.isError || !overview) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-destructive" />
<div>
<p className="font-semibold">
Unable to open the assignment workspace.
</p>
<p className="mt-1 text-sm text-muted-foreground">
Ticket information is unavailable.
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={backToTicket}>
Back to ticket
</Button>
<Button type="button" onClick={() => void overviewQuery.refetch()}>
Retry
</Button>
</div>
</CardContent>
</Card>
);
}
const ticketName = overview.ticket_name || 'Unnamed ticket';
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">
<header className="flex items-start gap-3">
<Button
type="button"
variant="secondary"
size="icon"
onClick={backToTicket}
aria-label="Back to ticket"
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">
Assign Ticket
</h1>
<Badge
variant="secondary"
className="max-w-full font-mono text-xs"
title={ticketName}
>
<span className="truncate">{ticketName}</span>
</Badge>
</div>
</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="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<ChooseIssuesSection
ticketId={ticketId}
issueTypes={overview.ai_result.detections_by_class}
selectedIssueTypes={selectedIssueTypes}
cacheRevision={assignmentRevision}
startPoint={startPoint}
endPoint={endPoint}
onSelectedIssueTypesChange={setSelectedIssueTypes}
onStartPointChange={setStartPoint}
onEndPointChange={setEndPoint}
/>
</div>
<aside className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={
<Card>
<CardContent className="flex min-h-72 flex-col items-center justify-center text-center">
<LockKeyhole className="size-8 text-muted-foreground" />
<p className="mt-3 font-semibold">
Assignment access required
</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
You do not have permission to assign this ticket.
</p>
<Button
type="button"
variant="outline"
className="mt-4"
onClick={backToTicket}
>
Back to ticket
</Button>
</CardContent>
</Card>
}
>
<AssignTicketForm
ticketId={ticketId}
selectedClassNames={selectedIssueTypes}
startPoint={startPoint}
endPoint={endPoint}
onAssigned={() => {
setStartPoint(null);
setEndPoint(null);
setAssignmentRevision((revision) => revision + 1);
}}
/>
</PermissionGuard>
</aside>
</div>
</main>
);
}

View File

@@ -0,0 +1,316 @@
'use client';
import { useEffect } from 'react';
import {
BriefcaseBusiness,
CalendarClock,
ChevronDown,
Images,
ListChecks,
} from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import type { TicketActor, TicketAssignmentSummary } from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
import { TicketDetectionPreview } from './TicketDetectionPreview';
interface TicketAssignmentsCardProps {
ticketId: string;
videoId?: string | null;
initialAssignments?: TicketAssignmentSummary[];
selectedAssignmentId?: number;
onAssignmentChange: (assignmentId: number) => void;
}
function formatLabel(value: string) {
return value
.replaceAll('_', ' ')
.replace(/\b\w/g, (character) => character.toUpperCase());
}
function AssignmentClassBadge({ className }: { className: string }) {
const visual = getDefectVisual(className);
const IssueIcon = visual.icon;
return (
<Badge
variant="outline"
className="h-6 gap-1.5 px-2 font-normal"
style={{
borderColor: `${visual.boundingBoxColor}66`,
backgroundColor: `${visual.boundingBoxColor}14`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon />
{formatLabel(className)}
</Badge>
);
}
function AssignmentProgress({
assignment,
}: {
assignment: TicketAssignmentSummary;
}) {
const total = Math.max(assignment.item_count, 0);
const approved = Math.max(assignment.approved_detections, 0);
const progress = total > 0 ? Math.min((approved / total) * 100, 100) : 0;
return (
<div className="min-w-0 space-y-2">
<div className="flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="font-medium text-foreground">Repair progress</span>
<span className="tabular-nums text-muted-foreground">
{approved} of {total} approved
</span>
</div>
<Progress value={assignment.is_complete ? 100 : progress} />
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs">
<span className="whitespace-nowrap text-emerald-600">
{assignment.approved_detections} approved
</span>
<span className="whitespace-nowrap text-amber-600">
{assignment.pending_detections} pending
</span>
<span className="whitespace-nowrap text-muted-foreground">
{assignment.not_submitted_detections} not submitted
</span>
</div>
</div>
);
}
function AssignmentOverview({
assignment,
}: {
assignment: TicketAssignmentSummary;
}) {
const criteria = assignment.criteria;
const classNames = criteria?.class_names?.length
? criteria.class_names
: assignment.defect_class
? [assignment.defect_class]
: [];
const worker: TicketActor = {
user_id: assignment.assigned_to_user_id,
name: assignment.assigned_to_name,
email: assignment.assigned_to_email ?? null,
avatar_url: assignment.assigned_to_avatar_url ?? null,
};
return (
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
<div className="min-w-0">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Assignment {assignment.id}
</p>
<PersonInfo person={worker} />
</div>
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
{classNames.map((className) => (
<AssignmentClassBadge key={className} className={className} />
))}
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ListChecks className="size-3.5" />
{assignment.item_count}{' '}
{assignment.item_count === 1 ? 'issue' : 'issues'}
</span>
{assignment.due_at ? (
<span className="flex min-w-0 max-w-full items-center gap-1.5 text-xs text-muted-foreground">
<CalendarClock className="size-3.5" />
Due {formatDate(assignment.due_at)}
</span>
) : null}
</div>
<AssignmentProgress assignment={assignment} />
</div>
);
}
export function TicketAssignmentsCard({
ticketId,
videoId,
initialAssignments,
selectedAssignmentId,
onAssignmentChange,
}: TicketAssignmentsCardProps) {
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
initialAssignments,
);
const assignments = (assignmentsQuery.data?.items ?? []).filter(
(assignment) => assignment.item_count > 0,
);
const selectedAssignment =
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
assignments[0];
const activeAssignmentId = selectedAssignment
? String(selectedAssignment.id)
: '';
const resolvedAssignmentId = selectedAssignment?.id;
useEffect(() => {
if (
resolvedAssignmentId !== undefined &&
resolvedAssignmentId !== selectedAssignmentId
) {
onAssignmentChange(resolvedAssignmentId);
}
}, [onAssignmentChange, resolvedAssignmentId, selectedAssignmentId]);
return (
<Card className="min-w-0 max-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">
<BriefcaseBusiness className="size-5 text-primary" />
Work Assignments
</CardTitle>
<CardDescription className="mt-1">
Issues grouped by assignee, scope, and repair progress.
</CardDescription>
</div>
{assignmentsQuery.data ? (
<Badge variant="default">
{assignments.length}{' '}
{assignments.length === 1 ? 'Assignment' : 'Assignments'}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="min-w-0 space-y-3 overflow-hidden px-3 sm:px-6">
{assignmentsQuery.isLoading ? (
Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="h-40 animate-pulse rounded-lg bg-muted"
/>
))
) : assignmentsQuery.isError ? (
<div className="rounded-lg border border-dashed p-6 text-center">
<p className="text-sm font-medium">
Unable to load work assignments.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => void assignmentsQuery.refetch()}
>
Try again
</Button>
</div>
) : selectedAssignment ? (
<div className="min-w-0 space-y-3">
<nav
aria-label="Work assignments"
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
>
<div
role="tablist"
aria-orientation="horizontal"
className="flex w-max min-w-full border-b"
>
{assignments.map((assignment) => {
const assignmentId = String(assignment.id);
const isActive = assignmentId === activeAssignmentId;
return (
<button
key={assignment.id}
id={`assignment-tab-${assignmentId}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`assignment-panel-${assignmentId}`}
tabIndex={isActive ? 0 : -1}
className={cn(
'relative shrink-0 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
isActive
? 'text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => onAssignmentChange(assignment.id)}
>
Assignment {assignment.id}
</button>
);
})}
</div>
</nav>
<div
id={`assignment-panel-${activeAssignmentId}`}
role="tabpanel"
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
className="min-w-0"
>
<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 className="rounded-lg border border-dashed px-4 py-8 text-center">
<p className="text-sm font-medium">No work assignments yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Work assignments will appear here after issues are allocated.
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,164 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Component, ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps {
ticketId: string;
ticket?: TicketOverviewDetail;
}
export function TicketDefectClassTabs({
ticketId,
ticket,
}: TicketDefectClassTabsProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const defectClassParam = searchParams.get('defect_class');
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
const defectClasses = useMemo(
() => defectClassesQuery.data?.defect_classes ?? [],
[defectClassesQuery.data?.defect_classes],
);
const [selectedClass, setSelectedClass] = useState<string>();
useEffect(() => {
if (!defectClasses.length) return;
const hasUrlClass = defectClasses.some(
(item) => item.class_name === defectClassParam,
);
if (hasUrlClass) {
setSelectedClass(defectClassParam ?? undefined);
return;
}
const firstClass = defectClasses[0].class_name;
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', firstClass);
setSelectedClass(firstClass);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
}, [defectClassParam, defectClasses, pathname, router, searchParams]);
const handleClassChange = (className: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', className);
setSelectedClass(className);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
};
const selectedIssue = ticket?.ai_result.detections_by_class.find(
(item) => item.class_name === selectedClass,
);
const selectedClassItem = defectClasses.find(
(item) => item.class_name === selectedClass,
);
const previewDisplayName =
selectedIssue?.display_name ??
selectedClassItem?.display_name ??
selectedClass ??
'Detection';
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
if (defectClassesQuery.isLoading) {
return (
<Card className="w-full rounded-lg">
<CardContent className="space-y-4 p-5">
<div className="h-5 w-24 animate-pulse rounded-lg bg-muted" />
<div className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-11 w-full animate-pulse rounded-lg bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
return null;
}
return (
<Tabs
value={selectedClass}
onValueChange={handleClassChange}
orientation="vertical"
className="w-full"
>
<Card className="w-full">
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
<div className="space-y-4 px-5">
<div className="flex items-center gap-2">
<ListChecks className="size-5 shrink-0 text-primary" />
<h2 className="text-base font-semibold">Detected Issues</h2>
<Badge
variant="secondary"
className="min-w-5 min-h-5 justify-center rounded-full"
>
{defectClasses.length}
</Badge>
</div>
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
{defectClasses.map((item) => {
const statusConfig =
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
return (
<TabsTrigger
key={item.class_name}
value={item.class_name}
className={cn(
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
'data-[state=active]:bg-muted data-[state=active]:text-foreground',
)}
>
<span className="min-w-0 truncate text-left font-medium">
{item.display_name}
</span>
<Badge
className={cn(
'shrink-0 rounded-full',
statusConfig.badgeClassName,
)}
>
<Component className="size-3" />
{statusConfig.label}
</Badge>
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="min-w-0 px-5">
<TicketClassDetectionPreview
ticketId={ticketId}
videoId={previewVideoId}
defectClassName={selectedClass}
displayName={previewDisplayName}
totalCount={selectedIssue?.count}
/>
</div>
</CardContent>
</Card>
</Tabs>
);
}

View File

@@ -3,32 +3,63 @@
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 { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types'; import type { TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction';
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">
<Button <PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
variant="secondary" <OpenRepairReviewAction
onClick={() => router.push('/ticket')} ticketId={ticket.id}
> hasPendingReview={ticket.has_pending_review}
<ArrowLeft className="size-4" /> pendingReviewCount={ticket.pending_review_count}
Back to List />
</Button> </PermissionGuard>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} />
</PermissionGuard>
</div> </div>
</div> </div>
); );

View File

@@ -1,39 +1,31 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
AlertTriangle,
ChevronLeft,
ChevronRight,
ImageIcon,
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';
import { getDefectVisual } from '@/constants/defectVisualConfig'; import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { DetectionRepairProof } from '@/types';
import { import {
useDiscardDetectionMutation, useDiscardDetectionMutation,
useTicketClassDetectionsQuery, useTicketDetectionsQuery,
} from '../../hooks/useTicketQueries'; } from '../../hooks/useTicketQueries';
import { DetectionDiscardDialog } from './DetectionDiscardDialog'; import { DetectionDiscardDialog } from './DetectionDiscardDialog';
import { RepairProofGallery } from './repair/RepairProofGallery';
import { RepairSubmittedBanner } from './repair/RepairSubmittedBanner';
interface TicketClassDetectionPreviewProps { interface TicketDetectionPreviewProps {
ticketId: string; ticketId: string;
videoId?: string | null; videoId?: string | null;
defectClassName: string; assignmentId?: number;
displayName: string; assignmentStatus?: 'unassigned';
totalCount?: number; onDetectionCountChange?: (count: number | undefined) => void;
} }
const DETECTION_PAGE_SIZE = 10;
function DetectionMetadataBar({ function DetectionMetadataBar({
items, items,
}: { }: {
@@ -63,45 +55,7 @@ function DetectionMetadataBar({
); );
} }
function DetectionRepairProofPanel({ function TicketDetectionPreviewSkeleton() {
repairProof,
}: {
repairProof: DetectionRepairProof;
}) {
if (!repairProof.submitted) {
return null;
}
return (
<section className="space-y-4 rounded-lg border bg-muted/20 p-4">
<div className="flex items-center gap-2">
<ImageIcon className="size-5 text-primary" />
<h4 className="font-semibold">Repair Proof</h4>
</div>
{repairProof.submitted_at ? (
<RepairSubmittedBanner
submittedAt={repairProof.submitted_at}
submitterName="Ticket Worker"
/>
) : null}
<RepairProofGallery
imageUrls={repairProof.proof_image_urls}
maxVisibleImages={4}
/>
<div className="space-y-1.5">
<p className="text-xs font-semibold text-foreground">Repair Notes</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
{repairProof.notes?.trim() || 'No repair notes provided.'}
</p>
</div>
</section>
);
}
function TicketClassDetectionPreviewSkeleton() {
return ( return (
<> <>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
@@ -140,58 +94,86 @@ function TicketClassDetectionPreviewSkeleton() {
); );
} }
export function TicketClassDetectionPreview({ export function TicketDetectionPreview({
ticketId, ticketId,
videoId, videoId,
defectClassName, assignmentId,
displayName, assignmentStatus,
totalCount, onDetectionCountChange,
}: TicketClassDetectionPreviewProps) { }: TicketDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false); const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const visual = getDefectVisual(defectClassName); const isUnassignedPreview = assignmentStatus === 'unassigned';
const IssueIcon = visual.icon; const pageStart =
Math.floor(currentIndex / DETECTION_PAGE_SIZE) * DETECTION_PAGE_SIZE;
useEffect(() => { useEffect(() => {
setCurrentIndex(0); setCurrentIndex(0);
}, [defectClassName]); }, [assignmentId, assignmentStatus, videoId]);
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
class_name: defectClassName, skip: pageStart,
skip: currentIndex, limit: DETECTION_PAGE_SIZE,
limit: 1, assignment_id: isUnassignedPreview ? undefined : assignmentId,
assignment_status: isUnassignedPreview
? ('unassigned' as const)
: undefined,
sort: 'timestamp_asc', sort: 'timestamp_asc',
}), }),
[currentIndex, defectClassName], [assignmentId, isUnassignedPreview, pageStart],
); );
const detectionsQuery = useTicketClassDetectionsQuery( const detectionsQuery = useTicketDetectionsQuery(
videoId ?? undefined, videoId ?? undefined,
queryParams, queryParams,
{ keepPreviousPage: true },
); );
const detectionResult = detectionsQuery.data;
const activeDetection = detectionsQuery.isPlaceholderData
? undefined
: detectionResult?.items[currentIndex - pageStart];
const confirmedDetectionCount = detectionResult?.total;
const detectionCount = confirmedDetectionCount ?? 0;
const activeVisual = getDefectVisual(
activeDetection?.detection.class_name ?? '',
);
const ActiveIssueIcon = activeVisual.icon;
const activeDisplayName =
activeDetection?.detection.display_name ?? 'Detection';
const discardMutation = useDiscardDetectionMutation( const discardMutation = useDiscardDetectionMutation(
ticketId, ticketId,
videoId ?? undefined, videoId ?? undefined,
defectClassName,
); );
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
useEffect(() => { useEffect(() => {
if (detectionCount === 0 && currentIndex !== 0) { if (confirmedDetectionCount === undefined) return;
if (confirmedDetectionCount === 0 && currentIndex !== 0) {
setCurrentIndex(0); setCurrentIndex(0);
return; return;
} }
if (detectionCount > 0 && currentIndex > detectionCount - 1) { if (
setCurrentIndex(detectionCount - 1); confirmedDetectionCount > 0 &&
currentIndex > confirmedDetectionCount - 1
) {
setCurrentIndex(confirmedDetectionCount - 1);
} }
}, [currentIndex, detectionCount]); }, [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">
@@ -200,28 +182,15 @@ export function TicketClassDetectionPreview({
<span <span
className={cn( className={cn(
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg', 'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
visual.cardClassName, activeVisual.cardClassName,
)} )}
> >
<IssueIcon className={cn('size-5', visual.colorClassName)} /> <ActiveIssueIcon
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>{displayName}</h3>
<Badge
variant="secondary"
className={cn(
'rounded-lg',
visual.cardClassName,
visual.colorClassName,
)}
>
{detectionCount} Detections
</Badge>
</div>
<p className="text-muted-foreground">
Review detections in ascending timestamp order.
</p>
</div> </div>
</div> </div>
@@ -262,28 +231,27 @@ export function TicketClassDetectionPreview({
</div> </div>
</div> </div>
{detectionsQuery.isLoading && !detectionsQuery.data ? ( {isInitialLoading ? (
<TicketClassDetectionPreviewSkeleton /> <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>
) : ( ) : (
<> <>
@@ -305,7 +273,7 @@ export function TicketClassDetectionPreview({
className="min-w-0" className="min-w-0"
latitude={activeDetection.location.latitude} latitude={activeDetection.location.latitude}
longitude={activeDetection.location.longitude} longitude={activeDetection.location.longitude}
label={`${displayName} - Frame ${activeDetection.frame.number}`} label={`${activeDetection.detection.display_name} - Frame ${activeDetection.frame.number}`}
title="Location on Map" title="Location on Map"
variant="plain" variant="plain"
aspectRatio="16 / 9" aspectRatio="16 / 9"
@@ -330,36 +298,34 @@ export function TicketClassDetectionPreview({
]} ]}
/> />
{activeDetection.repair_proof ? ( {isUnassignedPreview ? (
<DetectionRepairProofPanel <>
repairProof={activeDetection.repair_proof} <div className="flex justify-end">
/> <Button
type="button"
variant="destructive"
onClick={() => setIsDiscardDialogOpen(true)}
>
<Trash2 />
Discard detection
</Button>
</div>
<DetectionDiscardDialog
open={isDiscardDialogOpen}
detectionLabel={activeDetection.detection.display_name}
isPending={discardMutation.isPending}
onOpenChange={setIsDiscardDialogOpen}
onSubmit={async (payload) => {
await discardMutation.mutateAsync({
detectionId: activeDetection.detection.id,
payload,
});
setIsDiscardDialogOpen(false);
}}
/>
</>
) : null} ) : null}
<div className="flex justify-end">
<Button
type="button"
variant="destructive"
onClick={() => setIsDiscardDialogOpen(true)}
>
<Trash2 />
Discard detection
</Button>
</div>
<DetectionDiscardDialog
open={isDiscardDialogOpen}
detectionLabel={activeDetection.detection.display_name}
isPending={discardMutation.isPending}
onOpenChange={setIsDiscardDialogOpen}
onSubmit={async (payload) => {
await discardMutation.mutateAsync({
detectionId: activeDetection.detection.id,
payload,
});
setIsDiscardDialogOpen(false);
}}
/>
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,34 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { TicketDetectionPreview } from './TicketDetectionPreview';
interface TicketDetectionPreviewCardProps {
ticketId: string;
videoId?: string | null;
assignmentId: number;
}
export function TicketDetectionPreviewCard({
ticketId,
videoId,
assignmentId,
}: TicketDetectionPreviewCardProps) {
if (!videoId) return null;
return (
<Card className="w-full">
<CardContent className="p-5">
<div className="min-w-0">
<TicketDetectionPreview
key={`${videoId}:${assignmentId}`}
ticketId={ticketId}
videoId={videoId}
assignmentId={assignmentId}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,28 +1,56 @@
'use client'; 'use client';
import { History, MessageSquareText } from 'lucide-react'; import { History, MessageSquareText, RefreshCw } 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 { Timeline, TimelineItem } from '@/components/ui/timeline'; import { Timeline, TimelineItem } from '@/components/ui/timeline';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TicketDetail, TicketHistoryItem, TicketHistorySource } from '@/types'; import type {
TicketTimelineEventType,
TicketTimelineItem,
TicketTimelineNoteKind,
TicketTimelineSource,
TicketTimelineUser,
} from '@/types';
import { formatTimelineDateTime } from '@/utils/date'; import { formatTimelineDateTime } from '@/utils/date';
import { getPersonLabel } from '@/utils/person'; import { getPersonLabel } from '@/utils/person';
import { useTicketTimelineQuery } from '../../hooks/useTicketQueries';
import { TicketTimelineSkeleton } from './TicketTimelineSkeleton';
function TimelineDate({ value }: { value: string }) { function TimelineDate({ value }: { value: string }) {
const { date, time } = formatTimelineDateTime(value); const { date, time } = formatTimelineDateTime(value);
return ( return (
<time dateTime={value} className="block tabular-nums"> <time dateTime={value} className="block tabular-nums">
<span className="block text-sm font-semibold text-foreground">{date}</span> <span className="block text-sm font-semibold text-foreground">
{date}
</span>
<span className="block text-xs text-muted-foreground">{time}</span> <span className="block text-xs text-muted-foreground">{time}</span>
</time> </time>
); );
} }
function HistoryNote({ item }: { item: TicketHistoryItem }) { function getNoteKindLabel(kind: TicketTimelineNoteKind) {
const noteText = item.note?.text?.trim(); switch (kind) {
case 'system_note':
return 'System note';
case 'repair_note':
return 'Repair note';
case 'review_comment':
return 'Review comment';
default:
return 'Note';
}
}
function HistoryNote({ item }: { item: TicketTimelineItem }) {
const note = item.note;
if (!note) return null;
const noteText = note.text.trim();
if (!noteText) return null; if (!noteText) return null;
return ( return (
@@ -30,7 +58,7 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) {
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200"> <div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
<MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" /> <MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
<span> <span>
Note by {getPersonLabel(item.note?.author ?? item.actor)} {getNoteKindLabel(note.kind)} by {getPersonLabel(note.author)}
</span> </span>
</div> </div>
<p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90"> <p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90">
@@ -40,22 +68,21 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) {
); );
} }
function getSourceLabel(source: TicketHistorySource) { function getSourceLabel(source: TicketTimelineSource) {
return source === 'system' ? 'System Generated' : 'User Action'; return source === 'system' ? 'System Generated' : 'User Action';
} }
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) { function SystemHistoryEntry({ item }: { item: TicketTimelineItem }) {
const noteText = item.note?.text?.trim(); const noteText = item.note?.text?.trim();
return ( return (
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5"> <div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
<Badge <Badge className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
>
{getSourceLabel(item.source)} {getSourceLabel(item.source)}
</Badge> </Badge>
<h3 className="mt-2 text-sm font-semibold text-foreground">{item.title}</h3> <h3 className="mt-2 text-sm font-semibold text-foreground">
{item.title}
</h3>
{noteText ? ( {noteText ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground"> <p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{noteText} {noteText}
@@ -65,17 +92,52 @@ function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
); );
} }
function UserHistoryEntry({ item }: { item: TicketHistoryItem }) { function isSameUser(actor: TicketTimelineUser, target: TicketTimelineUser) {
if (actor.user_id !== null && target.user_id !== null) {
return actor.user_id === target.user_id;
}
const actorEmail = actor.email?.trim().toLowerCase();
const targetEmail = target.email?.trim().toLowerCase();
if (actorEmail && targetEmail) return actorEmail === targetEmail;
return (
Boolean(actor.name?.trim()) &&
actor.name?.trim().toLowerCase() === target.name?.trim().toLowerCase()
);
}
function getTargetLabel(eventType: TicketTimelineEventType | null) {
switch (eventType) {
case 'assignment_added':
return 'Assigned to';
case 'assignment_replaced':
return 'Replacement worker';
case 'assignment_updated':
return 'Assigned worker';
case 'assignment_deleted':
return 'Removed worker';
default:
return 'Affected worker';
}
}
function UserHistoryEntry({ item }: { item: TicketTimelineItem }) {
const noteText = item.note?.text?.trim(); const noteText = item.note?.text?.trim();
const isAssigned = item.event_type === 'assigned'; const showTargetUser =
item.target_user && !isSameUser(item.actor, item.target_user);
return ( return (
<div className="rounded-lg border bg-card p-4"> <div className="rounded-lg border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3> <h3 className="mb-3 text-sm font-semibold text-foreground">
{item.title}
</h3>
<PersonInfo person={item.actor} /> <PersonInfo person={item.actor} />
{isAssigned && item.target_user ? ( {showTargetUser && item.target_user ? (
<div className="mt-3 space-y-1"> <div className="mt-3 space-y-1">
<p className="text-xs text-muted-foreground">Assigned to</p> <p className="text-xs text-muted-foreground">
{getTargetLabel(item.event_type)}
</p>
<PersonInfo person={item.target_user} /> <PersonInfo person={item.target_user} />
</div> </div>
) : null} ) : null}
@@ -84,17 +146,54 @@ function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
); );
} }
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) { function TimelineHeader() {
const history = ticket.history ?? []; return (
<div className="flex items-center gap-2">
<History className="size-5 text-primary" />
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
</div>
);
}
export function TicketHistoryCard({ ticketId }: { ticketId: string }) {
const timelineQuery = useTicketTimelineQuery(ticketId);
if (timelineQuery.isLoading) {
return <TicketTimelineSkeleton />;
}
const history = timelineQuery.data?.history ?? [];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <TimelineHeader />
<History className="size-5 text-primary" />
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
</div>
{history.length ? ( {timelineQuery.isError ? (
<div
role="alert"
className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6 text-center"
>
<p className="text-sm text-destructive">
Failed to load the ticket timeline.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
disabled={timelineQuery.isFetching}
onClick={() => timelineQuery.refetch()}
>
<RefreshCw
className={cn(
'size-4',
timelineQuery.isFetching && 'animate-spin',
)}
/>
Retry
</Button>
</div>
) : history.length ? (
<Timeline> <Timeline>
{history.map((item, index) => ( {history.map((item, index) => (
<TimelineItem <TimelineItem

View File

@@ -8,7 +8,7 @@ import {
MapPin, MapPin,
Monitor, Monitor,
UserRound, UserRound,
Play Play,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
@@ -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" />
@@ -172,79 +172,84 @@ export function TicketOverviewCard({
</Card> </Card>
) : null} ) : null}
<Card> <PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<CardHeader> <>
<CardTitle className="flex items-center gap-2"> <Card>
<Activity className="size-5 text-primary" /> <CardHeader>
AI Detection Summary <CardTitle className="flex items-center gap-2">
</CardTitle> <Activity className="size-5 text-primary" />
{analysisVideoId ? ( AI Detection Summary
<CardAction> </CardTitle>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}> {analysisVideoId ? (
<Button <CardAction>
variant="secondary" <Button
onClick={() => setIsAnalysisOpen(true)} variant="default"
> onClick={() => setIsAnalysisOpen(true)}
<Play className="mr-2 size-4" /> >
View Video <Play className="mr-2 size-4" />
</Button> View Video
</PermissionGuard> </Button>
</CardAction> </CardAction>
) : null} ) : null}
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
<SummaryCard
label="Total Detections"
value={ticket.ai_result.detection_count}
colorClassName="text-violet-600"
cardClassName="bg-violet-500/8"
/>
{ticket.ai_result.detections_by_class.map((item) => {
const visual = getDefectVisual(item.class_name);
return (
<SummaryCard <SummaryCard
key={item.class_name} label="Total Detections"
label={item.display_name} value={ticket.ai_result.detection_count}
value={item.count} colorClassName="text-violet-600"
colorClassName={visual.colorClassName} cardClassName="bg-violet-500/8"
cardClassName={visual.cardClassName}
/> />
); {ticket.ai_result.detections_by_class.map((item) => {
})} const visual = getDefectVisual(item.class_name);
</div> return (
{hasVideoMetrics ? ( <SummaryCard
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3"> key={item.class_name}
{videoMetadata?.fps ? ( label={item.display_name}
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} /> value={item.count}
colorClassName={visual.colorClassName}
cardClassName={visual.cardClassName}
/>
);
})}
</div>
{hasVideoMetrics ? (
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
{videoMetadata?.fps ? (
<VideoMetric
icon={Gauge}
label={`${videoMetadata.fps} FPS`}
/>
) : null}
{duration ? (
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
) : null}
{videoMetadata?.resolution?.label ? (
<VideoMetric
icon={Monitor}
label={`${videoMetadata.resolution.label} Resolution`}
/>
) : null}
</div>
) : null} ) : null}
{duration ? ( </CardContent>
<VideoMetric icon={Clock3} label={`${duration} Duration`} /> </Card>
) : null}
{videoMetadata?.resolution?.label ? (
<VideoMetric
icon={Monitor}
label={`${videoMetadata.resolution.label} Resolution`}
/>
) : null}
</div>
) : null}
</CardContent>
</Card>
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}> <Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
<DialogContent className="sm:max-w-5xl"> <DialogContent className="sm:max-w-5xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{analysisTitle}</DialogTitle> <DialogTitle>{analysisTitle}</DialogTitle>
<DialogDescription> <DialogDescription>
Video analysis with annotation overlay Video analysis with annotation overlay
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody> <DialogBody>
<TicketInlineAnalysisVideo ticket={ticket} /> <TicketInlineAnalysisVideo ticket={ticket} />
</DialogBody> </DialogBody>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
</PermissionGuard>
</div> </div>
); );
} }

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

@@ -1,75 +1,102 @@
'use client'; 'use client';
import { AlertTriangle } from 'lucide-react';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types'; import type { TicketAssignmentSummary } from '@/types';
import { formatDate } from '@/utils/date';
import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction'; import { NoTicketAction } from './actions/NoTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
import { RequestExtensionAction } from './actions/RequestExtensionAction'; import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction'; import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
interface TicketStatusActionsProps { interface TicketStatusActionsProps {
ticket: TicketDetail; ticketId: string;
assignment?: TicketAssignmentSummary;
} }
function getTicketAction(ticket: TicketDetail) { function OverdueLotAlert({
if (ticket.assignment_status === 'unassigned') { lotId,
return ( dueAt,
<PermissionGuard }: {
permissions={PERMISSIONS.TICKET.ASSIGN} lotId: number;
fallback={<NoTicketAction />} dueAt: string | null;
> }) {
<AssignTicketAction ticket={ticket} /> return (
</PermissionGuard> <div
); role="alert"
} className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-destructive"
>
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-semibold">Lot {lotId} is overdue</p>
<p className="mt-0.5 text-xs text-destructive/80">
The due date expired on {formatDate(dueAt)}.
</p>
</div>
</div>
);
}
if (ticket.assignment_status === 'assigned') { function getTicketAction(
ticketId: string,
assignment?: TicketAssignmentSummary,
) {
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 ticket={ticket} /> <RequestExtensionAction
ticketId={ticketId}
assignmentId={assignment.id}
dueAt={assignment.due_at}
canRequest={!assignment.is_complete}
/>
</PermissionGuard> </PermissionGuard>
} }
> >
<> <ReviewExtensionRequestAction
<AssignedContractorAction ticket={ticket} /> ticketId={ticketId}
<ReviewExtensionRequestAction ticket={ticket} /> assignmentId={assignment.id}
</> canReview={!assignment.is_complete}
/>
</PermissionGuard> </PermissionGuard>
); );
} }
if (ticket.assignment_status === 'under_review') { return null;
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW}
fallback={<NoTicketAction />}
>
<OpenRepairReviewAction ticket={ticket} />
</PermissionGuard>
);
}
if (
ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected'
) {
return <ClosedTicketAction ticket={ticket} />;
}
return <NoTicketAction />;
} }
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) { export function TicketStatusActions({
return <div className="space-y-3">{getTicketAction(ticket)}</div>; ticketId,
assignment,
}: TicketStatusActionsProps) {
const action = getTicketAction(ticketId, assignment);
return action ? (
<div className="space-y-3">
{assignment?.is_overdue ? (
<OverdueLotAlert lotId={assignment.id} dueAt={assignment.due_at} />
) : null}
{action}
</div>
) : null;
} }

View File

@@ -1,4 +1,3 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Timeline, TimelineItem } from '@/components/ui/timeline'; import { Timeline, TimelineItem } from '@/components/ui/timeline';
@@ -49,9 +48,9 @@ function TimelineEntrySkeleton({
); );
} }
export function TicketClassContentSkeleton() { export function TicketTimelineSkeleton() {
return ( return (
<div className="space-y-4" aria-label="Loading defect class details"> <div className="space-y-4" aria-label="Loading ticket timeline">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-lg" /> <Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-56 max-w-full" /> <Skeleton className="h-5 w-56 max-w-full" />
@@ -69,29 +68,3 @@ export function TicketClassContentSkeleton() {
</div> </div>
); );
} }
export function TicketClassActionsSkeleton() {
return (
<Card aria-label="Loading defect class actions">
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded-lg" />
<Skeleton className="h-5 w-32" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-20 w-full" />
</div>
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
);
}

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

@@ -1,111 +1,21 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { Users } from 'lucide-react';
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react'; import { useRouter } from 'next/navigation';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { ROUTES } from '@/utils/routes';
import { Textarea } from '@/components/ui/textarea';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import type { AssignableTicketUser, TicketDetail } from '@/types';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries'; export function AssignTicketAction({ ticketId }: { ticketId: string }) {
import { TicketActionCard } from './TicketActionCard'; const router = useRouter();
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticket.id);
const today = useMemo(() => {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}, []);
const maxDueMonth = useMemo(
() => new Date(today.getFullYear() + 20, 11),
[today],
);
return ( return (
<TicketActionCard icon={Users} title="Assign to Contractor"> <Button
<div className="space-y-4"> type="button"
<div className="space-y-2"> onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
<Label>Contractor *</Label> >
<AssignableWorkerSelect <Users />
value={selectedUserId} Assign Ticket
onValueChange={(userId, user) => { </Button>
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending}
/>
</div>
{selectedContractor ? (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">Contractor Details</p>
{selectedContractor.phone_number ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="size-4 shrink-0" />
{selectedContractor.phone_number}
</p>
) : null}
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="size-4 shrink-0" />
<span className="truncate">{selectedContractor.email}</span>
</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
{selectedContractor.role.name}
</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="assign-due-date">
Due Date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="assign-due-date"
value={dueDate}
onChange={setDueDate}
showLabel={false}
placeholder="Select due date"
disabled={assignMutation.isPending}
className="w-full"
startMonth={today}
endMonth={maxDueMonth}
disabledDates={{ before: today }}
/>
</div>
<div className="space-y-2">
<Label>Assignment Note (Optional)</Label>
<Textarea
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Add any instructions or notes for the contractor..."
/>
</div>
<Button
disabled={!selectedUserId || !dueDate || assignMutation.isPending}
onClick={() => {
if (!selectedUserId || !dueDate) return;
assignMutation.mutate({
assigned_to_user_id: Number(selectedUserId),
defect_class: ticket.defect_class,
due_at: dueDate.toISOString(),
note: assignNote || undefined,
});
}}
className="w-full"
>
<Send />
Assign Ticket
</Button>
</div>
</TicketActionCard>
); );
} }

View File

@@ -1,61 +0,0 @@
'use client';
import { CalendarClock, UserCheck } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import type { TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { TicketActionCard } from './TicketActionCard';
function AssignmentField({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate text-sm font-medium text-foreground">{value}</p>
</div>
);
}
export function AssignedContractorAction({ ticket }: { ticket: TicketDetail }) {
const worker = ticket.worker;
return (
<TicketActionCard icon={UserCheck} title="Assigned Contractor">
{worker ? (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<PersonInfo person={worker} size="lg" />
<Badge>
Assigned
</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-5 border-t pt-4">
<AssignmentField label="Role" value={worker.role?.name ?? '-'} />
<AssignmentField
label="Assigned By"
value={worker.assigned_by_name ?? '-'}
/>
<AssignmentField
label="Assigned On"
value={formatDate(worker.assigned_at)}
/>
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">Due Date</p>
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<CalendarClock className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{formatDate(worker.due_at)}</span>
</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Assignment details are not available.
</p>
)}
</TicketActionCard>
);
}

View File

@@ -1,123 +0,0 @@
'use client';
import type { ReactNode } from 'react';
import { Lock, MessageSquare } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { cn } from '@/lib/utils';
import type { TicketDetail } from '@/types';
import { formatDateOnly } from '@/utils/date';
function getClosedAt(ticket: TicketDetail) {
const approvedEntry = [...(ticket.history ?? [])]
.reverse()
.find(
(item) =>
item.event_type === 'repair_approved' ||
item.to_class_status === 'approved',
);
return (
approvedEntry?.created_at ??
ticket.reviewer?.reviewed_at ??
ticket.timestamps.updated_at
);
}
function MetaField({
label,
value,
valueClassName,
}: {
label: string;
value: ReactNode;
valueClassName?: string;
}) {
return (
<div className="space-y-1">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
{label}
</p>
<div
className={cn('text-sm font-semibold text-foreground', valueClassName)}
>
{value}
</div>
</div>
);
}
function FinalCommentsPanel({
comment,
}: {
comment: string | null | undefined;
}) {
if (!comment?.trim()) {
return (
<div className="flex min-h-28 flex-col items-center justify-center rounded-lg border border-dashed border-border bg-muted/10 px-4 py-6 text-center">
<MessageSquare className="mb-2 size-5 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No comments added yet</p>
</div>
);
}
return (
<div className="rounded-lg border bg-muted/20 px-4 py-3">
<p className="text-sm leading-relaxed text-foreground">
{comment.trim()}
</p>
</div>
);
}
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
const reviewer = ticket.reviewer;
const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket);
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Resolution Details</CardTitle>
<CardDescription>Review record for {ticket.ticket_name}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-4 rounded-lg border bg-muted/20 p-4">
<MetaField
label="Reviewed By"
value={<PersonInfo person={reviewer} size="lg" />}
/>
<MetaField
label="Reviewed Date"
value={formatDateOnly(reviewedAt)}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
Review Comment
</p>
<FinalCommentsPanel comment={reviewer?.review_comment} />
</div>
<Button
type="button"
variant="secondary"
className="w-full text-muted-foreground"
disabled
>
<Lock className="size-4" />
Ticket Finalized
</Button>
</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

@@ -1,37 +1,45 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ArrowRight, 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 type { TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
import { TicketActionCard } from './TicketActionCard'; interface OpenRepairReviewActionProps {
ticketId: string;
hasPendingReview: boolean;
pendingReviewCount: number;
}
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) { export function OpenRepairReviewAction({
ticketId,
hasPendingReview,
pendingReviewCount,
}: OpenRepairReviewActionProps) {
const router = useRouter(); const router = useRouter();
const count = Math.max(0, pendingReviewCount ?? 0);
return ( return (
<TicketActionCard icon={ClipboardCheck} title="Review Submitted Repairs"> <Button
<div className="space-y-4"> type="button"
<p className="text-sm leading-relaxed text-muted-foreground"> disabled={!hasPendingReview}
Review every submitted repair separately using its detection image, title={
location, and repair proof. hasPendingReview
</p> ? `${count} ${count === 1 ? 'repair is' : 'repairs are'} awaiting review`
<Button : 'No repairs are awaiting review'
type="button" }
className="w-full" onClick={() => router.push(ROUTES.TICKET_REVIEW(ticketId))}
onClick={() => >
router.push( <ClipboardCheck />
ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class), Review Repairs
) <Badge
} variant="secondary"
> className="h-5 min-w-5 rounded-full px-1.5 font-semibold tabular-nums"
Open Issue Review >
<ArrowRight /> {count}
</Button> </Badge>
</div> </Button>
</TicketActionCard>
); );
} }

View File

@@ -1,14 +1,31 @@
import { Clock3 } from 'lucide-react'; import { Clock3 } from 'lucide-react';
import type { TicketDetail } from '@/types';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel'; import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) { export function RequestExtensionAction({
ticketId,
assignmentId,
dueAt,
canRequest,
}: {
ticketId: string;
assignmentId: number;
dueAt: string | null;
canRequest: boolean;
}) {
return ( return (
<TicketActionCard icon={Clock3} title="Request Extension"> <TicketActionCard
<TicketExtensionRequestPanel ticket={ticket} /> icon={Clock3}
title={canRequest ? 'Request Extension' : 'Extension Request History'}
contextLabel={`Assignment ${assignmentId}`}
>
<TicketExtensionRequestPanel
ticketId={ticketId}
assignmentId={assignmentId}
dueAt={dueAt}
canRequest={canRequest}
/>
</TicketActionCard> </TicketActionCard>
); );
} }

View File

@@ -8,18 +8,22 @@ import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import type { TicketDetail } from '@/types';
import { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries'; import { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries';
const MAX_REASON_LENGTH = 300; const MAX_REASON_LENGTH = 300;
interface RequestExtensionFormProps { interface RequestExtensionFormProps {
ticket: TicketDetail; ticketId: string;
assignmentId: number;
currentDueAt: string | null;
} }
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) { export function RequestExtensionForm({
const currentDueAt = ticket.worker?.due_at; ticketId,
assignmentId,
currentDueAt,
}: RequestExtensionFormProps) {
const currentDueDate = useMemo( const currentDueDate = useMemo(
() => (currentDueAt ? new Date(currentDueAt) : undefined), () => (currentDueAt ? new Date(currentDueAt) : undefined),
[currentDueAt], [currentDueAt],
@@ -36,11 +40,9 @@ export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
); );
const [requestedDueDate, setRequestedDueDate] = useState<Date>(); const [requestedDueDate, setRequestedDueDate] = useState<Date>();
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const assignmentId = ticket.worker?.assignment_id;
const requestExtensionMutation = useRequestTicketExtensionMutation( const requestExtensionMutation = useRequestTicketExtensionMutation(
ticket.id, ticketId,
assignmentId, assignmentId,
ticket.defect_class,
); );
const isPending = requestExtensionMutation.isPending; const isPending = requestExtensionMutation.isPending;
const isRequestedDateValid = Boolean( const isRequestedDateValid = Boolean(
@@ -48,10 +50,7 @@ export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()), (!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
); );
const canSubmit = const canSubmit =
Boolean(assignmentId) && isRequestedDateValid && Boolean(reason.trim()) && !isPending;
isRequestedDateValid &&
Boolean(reason.trim()) &&
!isPending;
const handleSubmit = async () => { const handleSubmit = async () => {
if (!requestedDueDate || !canSubmit) return; if (!requestedDueDate || !canSubmit) return;

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import { useState } from 'react';
import { import {
ArrowRight, ArrowRight,
CalendarClock, CalendarClock,
@@ -10,24 +9,37 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import type { import type {
TicketDetail,
TicketExtensionRequest, TicketExtensionRequest,
TicketExtensionRequestsResponse, TicketExtensionRequestsResponse,
} from '@/types'; } from '@/types';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { import {
useReviewTicketExtensionMutation, MAX_EXTENSION_DECISION_NOTE_LENGTH,
useTicketExtensionRequestQuery, useExtensionRequestDecision,
} from '../../../hooks/useTicketQueries'; } from '../../../../extension-requests/hooks/useExtensionRequestDecision';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
import {
ExtensionRequestDetails,
getLatestRequest,
} from './TicketExtensionRequestPanel';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
const MAX_NOTE_LENGTH = 300;
function getPendingRequest(response: TicketExtensionRequestsResponse) { function getPendingRequest(response: TicketExtensionRequestsResponse) {
return response.items return response.items
.filter((request) => request.status === 'pending') .filter((request) => request.status === 'pending')
@@ -76,35 +88,35 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
} }
function ExtensionReviewForm({ function ExtensionReviewForm({
ticket, ticketId,
assignmentId,
request, request,
requests,
}: { }: {
ticket: TicketDetail; ticketId: string;
assignmentId: number;
request: TicketExtensionRequest; request: TicketExtensionRequest;
requests: TicketExtensionRequest[];
}) { }) {
const [note, setNote] = useState(''); const {
const [selectedAction, setSelectedAction] = useState< note,
'approve' | 'reject' | null setNote,
>(null); noteError,
const assignmentId = ticket.worker?.assignment_id as number; selectedAction,
const reviewMutation = useReviewTicketExtensionMutation( isPending,
ticket.id, isRejectConfirmationOpen,
assignmentId, setIsRejectConfirmationOpen,
ticket.defect_class, submitDecision,
); requestRejection,
} = useExtensionRequestDecision({ ticketId, assignmentId });
const extensionDays = getExtensionDays(request); const extensionDays = getExtensionDays(request);
const submitDecision = async (action: 'approve' | 'reject') => {
setSelectedAction(action);
try {
await reviewMutation.mutateAsync({ action, note: note.trim() });
} finally {
setSelectedAction(null);
}
};
return ( return (
<TicketActionCard icon={CalendarClock} title="Extension Request Details"> <TicketActionCard
icon={CalendarClock}
title="Extension Request Details"
contextLabel={`Assignment ${assignmentId}`}
>
<div className="space-y-4"> <div className="space-y-4">
<Requester request={request} /> <Requester request={request} />
@@ -141,25 +153,39 @@ function ExtensionReviewForm({
</section> </section>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="extension-manager-note"> <div className="space-y-0.5">
Add Manager Comments{' '} <Label htmlFor="extension-manager-note">Add Manager Comments</Label>
<span className="text-muted-foreground">(Optional)</span> <p className="text-xs text-muted-foreground">
</Label> Required for rejection and optional for approval.
</p>
</div>
<div className="relative"> <div className="relative">
<Textarea <Textarea
id="extension-manager-note" id="extension-manager-note"
value={note} value={note}
onChange={(event) => onChange={(event) => {
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH)) setNote(event.target.value);
} }}
placeholder="Add a note explaining your decision..." placeholder="Add a note explaining your decision..."
disabled={reviewMutation.isPending} disabled={isPending}
aria-invalid={Boolean(noteError)}
aria-describedby={
noteError ? 'extension-manager-note-error' : undefined
}
className="min-h-24 resize-none pb-8" className="min-h-24 resize-none pb-8"
/> />
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground"> <span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
{note.length} / {MAX_NOTE_LENGTH} {note.length} / {MAX_EXTENSION_DECISION_NOTE_LENGTH}
</span> </span>
</div> </div>
{noteError ? (
<p
id="extension-manager-note-error"
className="text-xs text-destructive"
>
{noteError}
</p>
) : null}
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -167,8 +193,8 @@ function ExtensionReviewForm({
type="button" type="button"
variant="outline" variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive" className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
disabled={reviewMutation.isPending} disabled={isPending}
onClick={() => submitDecision('reject')} onClick={requestRejection}
> >
{selectedAction === 'reject' ? ( {selectedAction === 'reject' ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
@@ -179,8 +205,8 @@ function ExtensionReviewForm({
</Button> </Button>
<Button <Button
type="button" type="button"
disabled={reviewMutation.isPending} disabled={isPending}
onClick={() => submitDecision('approve')} onClick={() => void submitDecision('approve')}
> >
{selectedAction === 'approve' ? ( {selectedAction === 'approve' ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
@@ -190,29 +216,63 @@ function ExtensionReviewForm({
Approve Extension Approve Extension
</Button> </Button>
</div> </div>
<ExtensionRequestHistoryDialog requests={requests} />
<AlertDialog
open={isRejectConfirmationOpen}
onOpenChange={setIsRejectConfirmationOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reject this extension request? This
decision cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isPending}
onClick={() => void submitDecision('reject')}
>
Yes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</TicketActionCard> </TicketActionCard>
); );
} }
export function ReviewExtensionRequestAction({ export function ReviewExtensionRequestAction({
ticket, ticketId,
assignmentId,
canReview,
}: { }: {
ticket: TicketDetail; ticketId: string;
assignmentId: number;
canReview: boolean;
}) { }) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id, ticketId,
assignmentId, assignmentId,
); );
if (!assignmentId || extensionRequestQuery.isError) { if (extensionRequestQuery.isError) {
return null; return null;
} }
if (extensionRequestQuery.isLoading) { if (extensionRequestQuery.isLoading) {
return ( return (
<TicketActionCard icon={Clock3} title="Extension Request"> <TicketActionCard
icon={Clock3}
title="Extension Request"
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" />
<span className="text-sm">Checking for requests...</span> <span className="text-sm">Checking for requests...</span>
@@ -225,7 +285,32 @@ export function ReviewExtensionRequestAction({
? getPendingRequest(extensionRequestQuery.data) ? getPendingRequest(extensionRequestQuery.data)
: null; : null;
if (!pendingRequest) return null; const requests = extensionRequestQuery.data?.items ?? [];
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />; 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 (
<ExtensionReviewForm
ticketId={ticketId}
assignmentId={assignmentId}
request={pendingRequest}
requests={requests}
/>
);
} }

View File

@@ -2,6 +2,7 @@
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -9,6 +10,7 @@ interface TicketActionCardProps {
icon: LucideIcon; icon: LucideIcon;
iconClassName?: string; iconClassName?: string;
title: string; title: string;
contextLabel?: string;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -16,15 +18,23 @@ export function TicketActionCard({
icon: Icon, icon: Icon,
iconClassName, iconClassName,
title, title,
contextLabel,
children, children,
}: TicketActionCardProps) { }: TicketActionCardProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <div className="flex items-center justify-between gap-3">
<Icon className={cn('text-primary', iconClassName)} /> <CardTitle className="flex min-w-0 items-center gap-2 text-base">
{title} <Icon className={cn('shrink-0 text-primary', iconClassName)} />
</CardTitle> <span className="truncate">{title}</span>
</CardTitle>
{contextLabel ? (
<Badge variant="default" className="shrink-0">
{contextLabel}
</Badge>
) : null}
</div>
</CardHeader> </CardHeader>
<CardContent>{children}</CardContent> <CardContent>{children}</CardContent>
</Card> </Card>

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';
@@ -14,13 +15,13 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { import type {
TicketDetail,
TicketExtensionRequest, TicketExtensionRequest,
TicketExtensionRequestsResponse, TicketExtensionRequestsResponse,
} from '@/types'; } from '@/types';
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 {
@@ -45,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',
@@ -80,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;
@@ -99,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;
@@ -125,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)}
@@ -161,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}
@@ -177,33 +192,36 @@ 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>
); );
} }
export function TicketExtensionRequestPanel({ export function TicketExtensionRequestPanel({
ticket, ticketId,
assignmentId,
dueAt,
canRequest,
}: { }: {
ticket: TicketDetail; ticketId: string;
assignmentId: number;
dueAt: string | null;
canRequest: boolean;
}) { }) {
const assignmentId = ticket.worker?.assignment_id; const [isRequestFormOpen, setIsRequestFormOpen] = useState(false);
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id, ticketId,
assignmentId, assignmentId,
); );
if (!assignmentId) {
return (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
No active assignment is available for this ticket.
</div>
);
}
if (extensionRequestQuery.isLoading) { if (extensionRequestQuery.isLoading) {
return ( return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground"> <div className="flex min-h-40 items-center justify-center text-muted-foreground">
@@ -237,8 +255,50 @@ export function TicketExtensionRequestPanel({
: null; : null;
if (!latestRequest) { if (!latestRequest) {
return <RequestExtensionForm ticket={ticket} />; if (!canRequest) {
return (
<p className="text-sm text-muted-foreground">
No extension request history is available for this ticket assignment.
</p>
);
}
return (
<RequestExtensionForm
ticketId={ticketId}
assignmentId={assignmentId}
currentDueAt={dueAt}
/>
);
} }
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,27 +0,0 @@
import { CheckCircle2 } from 'lucide-react';
import { formatDate } from '@/utils/date';
interface RepairSubmittedBannerProps {
submittedAt: string;
submitterName: string;
}
export function RepairSubmittedBanner({
submittedAt,
submitterName,
}: RepairSubmittedBannerProps) {
return (
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
Repair Submitted
</p>
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
{formatDate(submittedAt)} by {submitterName}
</p>
</div>
</div>
);
}

View File

@@ -1,27 +1,27 @@
'use client'; 'use client';
import { useState } from 'react';
import { useParams, useRouter, useSearchParams } 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 {
TicketOverviewCardSkeleton, TicketOverviewCardSkeleton,
TicketOverviewHeaderSkeleton, TicketOverviewHeaderSkeleton,
} from './components/TicketOverviewSkeleton'; } from './components/TicketOverviewSkeleton';
import { import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
TicketClassActionsSkeleton,
TicketClassContentSkeleton,
} from './components/TicketClassDetailSkeleton';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs'; import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketUnassignedIssuesCard } from './components/TicketUnassignedIssuesCard';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { import {
useTicketClassDetailQuery, useTicketAssignmentsQuery,
useTicketOverviewQuery, useTicketOverviewQuery,
} from '../hooks/useTicketQueries'; } from '../hooks/useTicketQueries';
@@ -29,17 +29,34 @@ export default function TicketDetailPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string }; const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined; 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 classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data; const overview = overviewQuery.data;
const classDetail = classDetailQuery.data; const assignmentsQuery = useTicketAssignmentsQuery(
const isClassDetailLoading = Boolean( ticketId,
defectClass && classDetailQuery.isLoading, overview?.assignments,
); );
const assignments = (assignmentsQuery.data?.items ?? []).filter(
(assignment) => assignment.item_count > 0,
);
const activeAssignment =
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
assignments[0];
const activeAssignmentId = activeAssignment?.id;
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId)); useTicketDetailEvents(ticketId, Boolean(ticketId));
if (overviewQuery.isError) { if (overviewQuery.isError) {
return ( return (
@@ -48,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
@@ -58,31 +75,48 @@ export default function TicketDetailPage() {
} }
return ( return (
<main className="relative z-10 space-y-5"> <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">
{overview ? ( <div className="xl:shrink-0">
<TicketDetailHeader ticket={overview} /> {overview ? (
) : ( <TicketDetailHeader ticket={overview} backHref={ticketListHref} />
<TicketOverviewHeaderSkeleton /> ) : (
)} <TicketOverviewHeaderSkeleton />
)}
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]"> <div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden">
<div className="space-y-5"> <div className="min-w-0 space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{overview ? ( {overview ? (
<TicketOverviewCard ticket={overview} /> <TicketOverviewCard ticket={overview} />
) : ( ) : (
<TicketOverviewCardSkeleton /> <TicketOverviewCardSkeleton />
)} )}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} /> {overview ? (
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null} <TicketAssignmentsCard
{!isClassDetailLoading && classDetail ? ( ticketId={ticketId}
<TicketHistoryCard ticket={classDetail} /> videoId={overview.video_id ?? overview.video?.id}
initialAssignments={overview.assignments}
selectedAssignmentId={activeAssignmentId}
onAssignmentChange={setSelectedAssignmentId}
/>
) : null} ) : null}
{overview ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<TicketUnassignedIssuesCard
ticketId={ticketId}
videoId={overview.video_id ?? overview.video?.id}
/>
</PermissionGuard>
) : null}
<TicketHistoryCard ticketId={ticketId} />
</div> </div>
<div className="space-y-3 self-start"> <div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null} {activeAssignment ? (
{!isClassDetailLoading && classDetail ? ( <TicketStatusActions
<TicketStatusActions ticket={classDetail} /> ticketId={ticketId}
assignment={activeAssignment}
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils';
import type { DetectionResultItem } from '@/types'; import type { DetectionResultItem } from '@/types';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { RepairProofGallery } from '../../../components/repair/RepairProofGallery'; import { RepairProofGallery } from '../../components/repair/RepairProofGallery';
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes'; import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = { const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {

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

@@ -79,8 +79,6 @@ export function IssueReviewQueue({
onLoadMore, onLoadMore,
}: IssueReviewQueueProps) { }: IssueReviewQueueProps) {
const loadMoreRef = useRef<HTMLDivElement>(null); const loadMoreRef = useRef<HTMLDivElement>(null);
const defectLabel = detections[0]?.detection.display_name ?? 'Detected';
useEffect(() => { useEffect(() => {
const target = loadMoreRef.current; const target = loadMoreRef.current;
if (!target || !hasNextPage || isFetchingNextPage) return; if (!target || !hasNextPage || isFetchingNextPage) return;
@@ -104,7 +102,7 @@ export function IssueReviewQueue({
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<ListChecks className="size-5 text-primary" /> <ListChecks className="size-5 text-primary" />
{defectLabel} Issues Repair Issues
<Badge variant="secondary" className="ml-auto rounded-full"> <Badge variant="secondary" className="ml-auto rounded-full">
{totalCount} {totalCount}
</Badge> </Badge>
@@ -115,6 +113,7 @@ export function IssueReviewQueue({
<div className="space-y-2 p-1"> <div className="space-y-2 p-1">
{detections.map((detection, index) => { {detections.map((detection, index) => {
const issueId = getIssueId(detection); const issueId = getIssueId(detection);
const defectLabel = detection.detection.display_name;
const status = getIssueReviewStatus(detection); const status = getIssueReviewStatus(detection);
const config = STATUS_CONFIG[status]; const config = STATUS_CONFIG[status];
const StatusIcon = config.icon; const StatusIcon = config.icon;

View File

@@ -18,9 +18,9 @@ import type { ReviewDetectionRepairProofPayload } from '@/types';
import { import {
useReviewDetectionRepairProofMutation, useReviewDetectionRepairProofMutation,
useTicketClassReviewDetectionsQuery, useTicketReviewDetectionsQuery,
useTicketOverviewQuery, useTicketOverviewQuery,
} from '../../../hooks/useTicketQueries'; } from '../../hooks/useTicketQueries';
import { IssueEvidencePanel } from './components/IssueEvidencePanel'; import { IssueEvidencePanel } from './components/IssueEvidencePanel';
import { IssueReviewAction } from './components/IssueReviewAction'; import { IssueReviewAction } from './components/IssueReviewAction';
import { IssueReviewHeader } from './components/IssueReviewHeader'; import { IssueReviewHeader } from './components/IssueReviewHeader';
@@ -39,14 +39,11 @@ const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
'rejected', 'rejected',
]; ];
export default function TicketClassReviewPage() { export default function TicketReviewPage() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { ticketId, defectClass } = useParams() as { const { ticketId } = useParams() as { ticketId: string };
ticketId: string;
defectClass: string;
};
const proofStatusParam = searchParams.get('proof_status'); const proofStatusParam = searchParams.get('proof_status');
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes( const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
proofStatusParam as ProofStatusFilter, proofStatusParam as ProofStatusFilter,
@@ -57,9 +54,8 @@ export default function TicketClassReviewPage() {
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
const ticket = overviewQuery.data; const ticket = overviewQuery.data;
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined; const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
const detectionsQuery = useTicketClassReviewDetectionsQuery( const detectionsQuery = useTicketReviewDetectionsQuery(
videoId, videoId,
defectClass,
proofStatus === 'all' ? 'submitted' : proofStatus, proofStatus === 'all' ? 'submitted' : proofStatus,
); );
const detectionResult = detectionsQuery.data?.pages[0]; const detectionResult = detectionsQuery.data?.pages[0];
@@ -72,7 +68,6 @@ export default function TicketClassReviewPage() {
const reviewMutation = useReviewDetectionRepairProofMutation({ const reviewMutation = useReviewDetectionRepairProofMutation({
ticketId, ticketId,
videoId, videoId,
defectClass,
}); });
const selectIssue = useCallback( const selectIssue = useCallback(
@@ -171,8 +166,7 @@ export default function TicketClassReviewPage() {
}, [detectionsQuery]); }, [detectionsQuery]);
const backToTicket = () => { const backToTicket = () => {
const params = new URLSearchParams({ defect_class: defectClass }); router.push(ROUTES.TICKET_DETAIL(ticketId));
router.push(`${ROUTES.TICKET_DETAIL(ticketId)}?${params.toString()}`);
}; };
if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) { if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) {
@@ -206,7 +200,7 @@ export default function TicketClassReviewPage() {
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center"> <CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-destructive" /> <AlertTriangle className="size-8 text-destructive" />
<div> <div>
<p className="font-semibold">Failed to load class detections.</p> <p className="font-semibold">Failed to load detections.</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
The review screen uses the detection API for its queue and The review screen uses the detection API for its queue and
evidence. evidence.
@@ -233,7 +227,6 @@ export default function TicketClassReviewPage() {
<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={defectClass}
currentIndex={0} currentIndex={0}
total={0} total={0}
proofStatus={proofStatus} proofStatus={proofStatus}
@@ -248,7 +241,7 @@ export default function TicketClassReviewPage() {
<div> <div>
<p className="font-semibold">No detections found.</p> <p className="font-semibold">No detections found.</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
There are no {defectClass} issues available for review. There are no issues available for review.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -262,7 +255,6 @@ export default function TicketClassReviewPage() {
<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

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { API_ROUTES } from '@/constants/apiRoutes'; import { API_ROUTES } from '@/constants/apiRoutes';
@@ -18,27 +18,19 @@ function isHeartbeatEvent(event: { type?: string } | null | undefined) {
export function useTicketDetailEvents( export function useTicketDetailEvents(
ticketId: string | undefined, ticketId: string | undefined,
defectClass: string | undefined,
enabled = true, enabled = true,
): void { ): void {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const defectClassRef = useRef(defectClass);
useEffect(() => {
defectClassRef.current = defectClass;
}, [defectClass]);
const refetchTicket = useCallback(() => { const refetchTicket = useCallback(() => {
if (!ticketId) return; if (!ticketId) return;
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) }); queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
if (defectClassRef.current) { queryClient.refetchQueries({
queryClient.refetchQueries({ queryKey: ticketKeys.assignments(ticketId),
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current), });
}); queryClient.refetchQueries({
} queryKey: ticketKeys.timeline(ticketId),
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
}); });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, [queryClient, ticketId]); }, [queryClient, ticketId]);

View File

@@ -2,6 +2,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
keepPreviousData,
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
useQuery, useQuery,
@@ -16,17 +17,23 @@ import type {
DiscardDetectionPayload, DiscardDetectionPayload,
RequestTicketExtensionPayload, RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload, ReviewDetectionRepairProofPayload,
ReviewTicketExtensionPayload, TicketAssignmentSummary,
TicketDetail, TicketAssignmentDetectionsParams,
TicketListParams, TicketListParams,
DetectionProofStatus, DetectionProofStatus,
VideoDetectionsParams, VideoDetectionsParams,
} from '@/types'; } from '@/types';
import { ticketKeys } from '../queries/ticketKeys'; import { ticketKeys } from '../queries/ticketKeys';
import { extensionRequestKeys } from '../../extension-requests/queries/extensionRequestKeys';
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 ?? ''),
@@ -68,43 +89,53 @@ export function useTicketOverviewQuery(ticketId: string | undefined) {
}); });
} }
export function useTicketClassDetailQuery( export function useTicketTimelineQuery(ticketId: string | undefined) {
ticketId: string | undefined,
defectClass: string | undefined,
) {
return useQuery({ return useQuery({
queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''), queryKey: ticketKeys.timeline(ticketId ?? ''),
queryFn: () => queryFn: () => ticketService.getTicketTimeline(ticketId as string),
ticketService.getTicketClassDetail(
ticketId as string,
defectClass as string,
),
enabled: Boolean(ticketId && defectClass),
});
}
export function useTicketDefectClassesQuery(ticketId: string | undefined) {
return useQuery({
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
enabled: Boolean(ticketId), enabled: Boolean(ticketId),
}); });
} }
export function useTicketClassDetectionsQuery( export function useTicketAssignmentsQuery(
ticketId: string | undefined,
initialAssignments?: TicketAssignmentSummary[],
) {
return useQuery({
queryKey: ticketKeys.assignments(ticketId ?? ''),
queryFn: () => ticketService.getTicketAssignments(ticketId as string),
enabled: Boolean(ticketId),
initialData:
initialAssignments !== undefined
? {
items: initialAssignments,
total: initialAssignments.length,
}
: undefined,
staleTime: 30 * 1000,
});
}
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_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',
search: params.search, search: params.search,
}), }),
[ [
params.assignment_id,
params.assignment_status,
params.class_name, params.class_name,
params.limit, params.limit,
params.min_confidence, params.min_confidence,
@@ -118,26 +149,56 @@ export function useTicketClassDetectionsQuery(
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 && queryParams.class_name), enabled: Boolean(enabled && videoId),
placeholderData: keepPreviousPage ? keepPreviousData : undefined,
staleTime: Infinity,
}); });
} }
export function useTicketClassReviewDetectionsQuery( export function useTicketClassDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
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(
videoId: string | undefined, videoId: string | undefined,
defectClass: string | undefined,
proofStatus?: DetectionProofStatus, proofStatus?: DetectionProofStatus,
) { ) {
const pageSize = 20; const pageSize = 20;
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ticketKeys.classReviewDetections( queryKey: ticketKeys.reviewDetections(videoId ?? '', proofStatus),
videoId ?? '',
defectClass ?? '',
proofStatus,
),
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
videoService.getVideoDetections(videoId as string, { videoService.getVideoDetections(videoId as string, {
class_name: defectClass,
proof_status: proofStatus, proof_status: proofStatus,
sort: 'timestamp_asc', sort: 'timestamp_asc',
skip: pageParam, skip: pageParam,
@@ -148,14 +209,13 @@ export function useTicketClassReviewDetectionsQuery(
const nextSkip = lastPage.skip + lastPage.items.length; const nextSkip = lastPage.skip + lastPage.items.length;
return nextSkip < lastPage.total ? nextSkip : undefined; return nextSkip < lastPage.total ? nextSkip : undefined;
}, },
enabled: Boolean(videoId && defectClass), enabled: Boolean(videoId),
}); });
} }
export function useDiscardDetectionMutation( export function useDiscardDetectionMutation(
ticketId: string, ticketId: string,
videoId: string | undefined, videoId: string | undefined,
defectClass: string,
) { ) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -179,13 +239,14 @@ export function useDiscardDetectionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
}),
]); ]);
}, },
onError: () => toast.error('Failed to discard detection'), onError: () => toast.error('Failed to discard detection'),
@@ -195,11 +256,9 @@ export function useDiscardDetectionMutation(
export function useReviewDetectionRepairProofMutation({ export function useReviewDetectionRepairProofMutation({
ticketId, ticketId,
videoId, videoId,
defectClass,
}: { }: {
ticketId: string; ticketId: string;
videoId: string | undefined; videoId: string | undefined;
defectClass: string;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -224,16 +283,14 @@ export function useReviewDetectionRepairProofMutation({
queryKey: ticketKeys.classDetectionLists(videoId), queryKey: ticketKeys.classDetectionLists(videoId),
}) })
: Promise.resolve(), : Promise.resolve(),
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
}),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
queryClient.invalidateQueries({
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'),
@@ -263,32 +320,30 @@ export function useAssignableTicketUsersQuery(enabled: boolean) {
}); });
} }
function mergeTicketDetailResponse(
current: TicketDetail | undefined,
next: TicketDetail,
) {
if (!current) return next;
return {
...current,
...next,
history: next.history ?? current.history,
};
}
export function useAssignTicketMutation(ticketId: string) { export function useAssignTicketMutation(ticketId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (payload: AssignTicketPayload) => mutationFn: (payload: AssignTicketPayload) =>
ticketService.assignTicket(ticketId, payload), ticketService.assignTicket(ticketId, payload),
onSuccess: (ticket, payload) => { onSuccess: async () => {
toast.success('Ticket assigned'); toast.success('Ticket assigned');
queryClient.setQueryData<TicketDetail>( await Promise.all([
ticketKeys.classDetail(ticketId, payload.defect_class), queryClient.invalidateQueries({
(current) => mergeTicketDetailResponse(current, ticket), queryKey: ticketKeys.overview(ticketId),
); }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
}),
]);
}, },
onError: () => toast.error('Failed to assign ticket'), onError: () => toast.error('Failed to assign ticket'),
}); });
@@ -297,7 +352,6 @@ export function useAssignTicketMutation(ticketId: string) {
export function useRequestTicketExtensionMutation( export function useRequestTicketExtensionMutation(
ticketId: string, ticketId: string,
assignmentId: number | undefined, assignmentId: number | undefined,
defectClass: string,
) { ) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -318,53 +372,39 @@ export function useRequestTicketExtensionMutation(
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId), queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
}); });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass), queryKey: extensionRequestKeys.lists(),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({
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'),
}); });
} }
export function useReviewTicketExtensionMutation(
ticketId: string,
assignmentId: number,
defectClass: string,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ReviewTicketExtensionPayload) =>
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
onSuccess: (_response, payload) => {
toast.success(
payload.action === 'approve'
? 'Extension request approved'
: 'Extension request rejected',
);
queryClient.invalidateQueries({
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to review extension request'),
});
}
export function useCloseTicketMutation(ticketId: string) { export function useCloseTicketMutation(ticketId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (payload: CloseTicketPayload) => mutationFn: (payload: CloseTicketPayload) =>
ticketService.closeTicket(ticketId, payload), ticketService.closeTicket(ticketId, payload),
onSuccess: (ticket, payload) => { onSuccess: () => {
toast.success('Ticket closed'); toast.success('Ticket closed');
queryClient.setQueryData<TicketDetail>( queryClient.invalidateQueries({
ticketKeys.classDetail(ticketId, payload.defect_class), queryKey: ticketKeys.overview(ticketId),
(current) => mergeTicketDetailResponse(current, ticket), });
); queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({
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,22 +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 { 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();
@@ -25,25 +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) => {
const defectClassQuery = ticket.default_defect_class const detailPath = ROUTES.TICKET_DETAIL(ticket.id);
? `?defect_class=${encodeURIComponent(ticket.default_defect_class)}` router.push(
: ''; searchParamsString ? `${detailPath}?${searchParamsString}` : detailPath,
);
router.push(`/ticket/${ticket.id}${defectClassQuery}`);
}, },
[router], [router, searchParamsString],
); );
const toolbar = useMemo( const toolbar = useMemo(
@@ -51,6 +130,7 @@ export default function TicketPage() {
<TicketFilters <TicketFilters
segmentId={segmentId} segmentId={segmentId}
onSegmentChange={handleSegmentChange} onSegmentChange={handleSegmentChange}
onClear={() => handleSegmentChange('')}
/> />
), ),
[handleSegmentChange, segmentId], [handleSegmentChange, segmentId],
@@ -64,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}
@@ -72,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,31 +1,39 @@
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,
classDetail: (ticketId: string, defectClass: string) => timeline: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const, [...ticketKeys.details(), ticketId, 'timeline'] as const,
classDetectionLists: (videoId: string) => classDetectionLists: (videoId: string) =>
[...ticketKeys.details(), 'video', videoId, 'detections'] as const, [...ticketKeys.details(), 'video', videoId, 'detections'] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) => classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...ticketKeys.classDetectionLists(videoId), params] as const, [...ticketKeys.classDetectionLists(videoId), params] as const,
classReviewDetections: ( reviewDetections: (videoId: string, proofStatus?: string) =>
videoId: string,
defectClass: string,
proofStatus?: string,
) =>
[ [
...ticketKeys.classDetectionLists(videoId), ...ticketKeys.classDetectionLists(videoId),
'class-review', 'review',
defectClass,
proofStatus ?? 'all', proofStatus ?? 'all',
] as const, ] as const,
defectClasses: (ticketId: string) => assignmentDetectionLists: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'defect-classes'] as const, [...ticketKeys.details(), ticketId, 'assignment-detections'] as const,
assignmentDetections: (
ticketId: string,
params: TicketAssignmentDetectionsParams,
) => [...ticketKeys.assignmentDetectionLists(ticketId), params] as const,
assignments: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignments'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) => extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
[ [
...ticketKeys.details(), ...ticketKeys.details(),

View File

@@ -1,85 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import {
Download,
Eye,
MoreVertical,
PlayCircle,
RotateCcw,
Ticket,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { UploadListItem } from '@/types';
interface UploadActionsMenuProps {
upload: UploadListItem;
onViewVideo: (upload: UploadListItem) => void;
}
export function UploadActionsMenu({
upload,
onViewVideo,
}: UploadActionsMenuProps) {
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={(event) => event.stopPropagation()}
>
<MoreVertical className="size-4" />
<span className="sr-only">Open actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{upload.raw_video_url ? (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onViewVideo(upload);
}}
>
<PlayCircle className="size-4" />
View Video
</DropdownMenuItem>
) : null}
<DropdownMenuItem>
<Eye /> View Details
</DropdownMenuItem>
<DropdownMenuItem>
<RotateCcw /> Retry Analysis
</DropdownMenuItem>
<DropdownMenuItem
disabled={!upload.ticket_id}
onClick={(event) => {
event.stopPropagation();
if (upload.ticket_id) {
router.push(`/ticket/${upload.ticket_id}`);
}
}}
>
<Ticket /> Open Ticket
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2 /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import Link from 'next/link';
import { ExternalLink, Ticket, UserRound } from 'lucide-react';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import { ROUTES } from '@/utils/routes';
import type { UploadListItem } from '@/types';
import { formatDate } from '@/utils/date';
import { UploadResultCell } from './UploadResultCell';
import { UploadStatusBadge } from './UploadStatusBadge';
interface UploadDetailsDialogProps {
upload: UploadListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UploadDetailsDialog({
upload,
open,
onOpenChange,
}: UploadDetailsDialogProps) {
const { hasPermission } = usePermissions();
const canOpenTicket = hasPermission(PERMISSIONS.TICKET.READ);
const details = [
{ label: 'Project', value: upload?.project },
{ label: 'Package', value: upload?.package },
{ label: 'Segment', value: upload?.segment },
{ label: 'Chainage', value: upload?.chainage },
{ label: 'Uploaded', value: formatDate(upload?.uploaded_at) },
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle>Upload Details</DialogTitle>
<DialogDescription>
Preview the inspection video and review its processing details.
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,0.8fr)]">
<section className="min-w-0 space-y-2">
<p className="text-sm font-medium">Video Preview</p>
<ProtectedVideoPlayer
url={open ? upload?.raw_video_url : undefined}
label={upload?.video_name}
className="rounded-lg"
unavailableTitle="Video preview unavailable"
/>
</section>
<aside className="min-w-0 space-y-4 rounded-lg border bg-muted/15 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs text-muted-foreground">Status</p>
<div className="mt-1.5">
{upload ? (
<UploadStatusBadge
status={upload.status}
progress={upload.progress}
/>
) : null}
</div>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Detections</p>
<Badge variant="secondary" className="mt-1.5 tabular-nums">
{upload?.total_detections ?? 0}
</Badge>
</div>
</div>
<div className="border-t pt-4">
<p className="mb-2 text-xs text-muted-foreground">Result</p>
{upload ? <UploadResultCell item={upload} /> : null}
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-t pt-4">
{details.map((detail) => (
<div
key={detail.label}
className={
detail.label === 'Uploaded' ? 'col-span-2' : undefined
}
>
<dt className="text-xs text-muted-foreground">
{detail.label}
</dt>
<dd className="mt-1 truncate text-sm font-medium">
{detail.value || '—'}
</dd>
</div>
))}
</dl>
<div className="flex min-w-0 items-start gap-2 border-t pt-4">
<UserRound className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{upload?.uploaded_by.name || 'Unknown uploader'}
</p>
<p className="truncate text-xs text-muted-foreground">
{upload?.uploaded_by.email || 'No email available'}
</p>
</div>
</div>
</aside>
</div>
</DialogBody>
<DialogFooter showCloseButton>
{upload?.ticket_id && canOpenTicket ? (
<Button asChild>
<Link href={ROUTES.TICKET_DETAIL(upload.ticket_id)}>
<Ticket />
Open Ticket
<ExternalLink />
</Link>
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
import type { UploadListItem } from '@/types'; import type { UploadListItem } from '@/types';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { UploadActionsMenu } from './UploadActionsMenu';
import { UploadResultCell } from './UploadResultCell'; import { UploadResultCell } from './UploadResultCell';
import { UploadStatusBadge } from './UploadStatusBadge'; import { UploadStatusBadge } from './UploadStatusBadge';
@@ -22,9 +21,7 @@ function resolveThumbnailUrl(value: string | null) {
return baseUrl ? `${baseUrl}${path}` : path; return baseUrl ? `${baseUrl}${path}` : path;
} }
export function useUploadListColumns( export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
onViewVideo: (upload: UploadListItem) => void,
): ColumnDef<UploadListItem>[] {
return useMemo( return useMemo(
() => [ () => [
{ {
@@ -57,7 +54,7 @@ export function useUploadListColumns(
}, },
{ {
id: 'uploaded_by', id: 'uploaded_by',
header: 'Uploader', header: 'Uploaded By',
size: 190, size: 190,
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>
@@ -133,24 +130,7 @@ export function useUploadListColumns(
meta: { disableTruncate: true }, meta: { disableTruncate: true },
cell: ({ row }) => <UploadResultCell item={row.original} />, cell: ({ row }) => <UploadResultCell item={row.original} />,
}, },
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 64,
minSize: 64,
enableSorting: false,
enableHiding: false,
meta: { disableTruncate: true },
cell: ({ row }) => (
<div className="flex justify-center">
<UploadActionsMenu
upload={row.original}
onViewVideo={onViewVideo}
/>
</div>
),
},
], ],
[onViewVideo], [],
); );
} }

View File

@@ -12,6 +12,7 @@ interface UploadTableProps {
skip: number; skip: number;
limit: number; limit: number;
total: number; total: number;
onUploadClick: (upload: UploadListItem) => void;
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
} }
@@ -23,6 +24,7 @@ export function UploadTable({
skip, skip,
limit, limit,
total, total,
onUploadClick,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}: UploadTableProps) { }: UploadTableProps) {
@@ -33,6 +35,7 @@ export function UploadTable({
isLoading={isLoading} isLoading={isLoading}
emptyTitle="No uploads found" emptyTitle="No uploads found"
emptyDescription="Upload your first road inspection video." emptyDescription="Upload your first road inspection video."
onRowClick={onUploadClick}
pagination={{ pagination={{
skip, skip,
limit, limit,

View File

@@ -1,41 +0,0 @@
'use client';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { UploadListItem } from '@/types';
interface VideoPreviewDialogProps {
upload: UploadListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VideoPreviewDialog({
upload,
open,
onOpenChange,
}: VideoPreviewDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Video Preview</DialogTitle>
</DialogHeader>
<DialogBody>
<ProtectedVideoPlayer
url={open ? upload?.raw_video_url : undefined}
className="rounded-lg"
unavailableTitle="Video preview unavailable"
/>
</DialogBody>
</DialogContent>
</Dialog>
);
}

View File

@@ -10,7 +10,7 @@ import type { UploadListItem } from '@/types';
import { CreateUploadDialog } from './components/CreateUploadDialog'; import { CreateUploadDialog } from './components/CreateUploadDialog';
import { useUploadListColumns } from './components/UploadListColumns'; import { useUploadListColumns } from './components/UploadListColumns';
import { UploadTable } from './components/UploadTable'; import { UploadTable } from './components/UploadTable';
import { VideoPreviewDialog } from './components/VideoPreviewDialog'; import { UploadDetailsDialog } from './components/UploadDetailsDialog';
import { useUploadFilters } from './hooks/useUploadFilters'; import { useUploadFilters } from './hooks/useUploadFilters';
import { useUploadListEvents } from './hooks/useUploadListEvents'; import { useUploadListEvents } from './hooks/useUploadListEvents';
import { useUploadsQuery } from './hooks/useUploadQueries'; import { useUploadsQuery } from './hooks/useUploadQueries';
@@ -20,13 +20,15 @@ export default function UploadPage() {
const uploadsQuery = useUploadsQuery({ skip, limit }); const uploadsQuery = useUploadsQuery({ skip, limit });
useUploadListEvents(); useUploadListEvents();
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [videoUpload, setVideoUpload] = useState<UploadListItem | null>(null); const [selectedUpload, setSelectedUpload] = useState<UploadListItem | null>(
null,
);
const uploads = uploadsQuery.data?.items ?? []; const uploads = uploadsQuery.data?.items ?? [];
const total = uploadsQuery.data?.total ?? 0; const total = uploadsQuery.data?.total ?? 0;
const isLoading = uploadsQuery.isLoading; const isLoading = uploadsQuery.isLoading;
const columns = useUploadListColumns(setVideoUpload); const columns = useUploadListColumns();
return ( return (
<> <>
@@ -50,17 +52,18 @@ export default function UploadPage() {
skip={skip} skip={skip}
limit={limit} limit={limit}
total={total} total={total}
onUploadClick={setSelectedUpload}
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
/> />
</main> </main>
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} /> <CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
<VideoPreviewDialog <UploadDetailsDialog
upload={videoUpload} upload={selectedUpload}
open={Boolean(videoUpload)} open={Boolean(selectedUpload)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setVideoUpload(null); if (!open) setSelectedUpload(null);
}} }}
/> />
</> </>

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

@@ -1,13 +1,13 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './typography.css'; @import './typography.css';
@import "tw-animate-css"; @import 'tw-animate-css';
@import "shadcn/tailwind.css"; @import 'shadcn/tailwind.css';
@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 {
@@ -152,7 +186,8 @@
html { html {
@apply font-sans; @apply font-sans;
} }
button:not(:disabled), [role="button"]:not(:disabled) { button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer; cursor: pointer;
} }
} }
@@ -164,4 +199,19 @@
.detection-video-player .detection-video-player
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) { .vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
display: flex; display: flex;
} }
.leaflet-tooltip.range-point-label {
border: 0;
background: transparent;
box-shadow: none;
padding: 0;
font-weight: 700;
text-shadow:
0 1px 2px rgb(255 255 255 / 95%),
0 0 4px rgb(255 255 255 / 95%);
}
.leaflet-tooltip.range-point-label::before {
display: none;
}

View File

@@ -1,57 +0,0 @@
import Image from 'next/image';
import { cn } from '@/lib/utils';
const sizeClasses = {
sm: 'size-8 rounded-lg border-2 p-0.5',
md: 'size-12 rounded-lg border-2 p-1.5',
lg: 'size-28 rounded-lg border-[5px] p-3',
} as const;
const variantClasses = {
default: 'border-white bg-primary shadow-md',
solid: 'border-white bg-primary',
glass: 'border-primary-foreground bg-primary/85 shadow-md',
sidebar: 'border-0 bg-primary',
} as const;
type LogoWrapperProps = {
alt?: string;
className?: string;
imageClassName?: string;
priority?: boolean;
size?: keyof typeof sizeClasses;
variant?: keyof typeof variantClasses;
};
export function LogoWrapper({
alt = 'RoadMonitor logo',
className,
imageClassName,
priority = false,
size = 'md',
variant = 'default',
}: LogoWrapperProps) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center',
sizeClasses[size],
variantClasses[variant],
className,
)}
>
<Image
src="/logo.png"
alt={alt}
width={112}
height={112}
className={cn(
'size-full object-contain brightness-0 invert',
imageClassName,
)}
priority={priority}
/>
</span>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { LogoWrapper } from '@/components/LogoWrapper'; import Image from 'next/image';
import { NavUser } from '@/components/nav-user'; import { NavUser } from '@/components/nav-user';
import { import {
filterMenuItems, filterMenuItems,
@@ -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,
@@ -55,7 +63,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
[authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') ||
'Admin', 'Admin',
email: authUser?.email || '', email: authUser?.email || '',
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
}; };
return ( return (
@@ -97,10 +104,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
function SidebarBrandControl({ isMobile }: { isMobile: boolean }) { function SidebarBrandControl({ isMobile }: { isMobile: boolean }) {
return ( return (
<div className="group/sidebar-brand relative flex size-8 shrink-0 items-center justify-center"> <div className="group/sidebar-brand relative flex size-8 shrink-0 items-center justify-center">
<LogoWrapper <Image
size="sm" src="/color_logo.svg"
variant="sidebar" alt="RoadMonitor logo"
className="transition-opacity duration-150 group-data-[collapsible=icon]:group-hover/sidebar-brand:opacity-0 group-data-[collapsible=icon]:group-focus-within/sidebar-brand:opacity-0" width={81}
height={58}
className="h-auto w-10 transition-opacity duration-150 group-data-[collapsible=icon]:group-hover/sidebar-brand:opacity-0 group-data-[collapsible=icon]:group-focus-within/sidebar-brand:opacity-0"
priority
/> />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -122,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)) ??
@@ -134,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

@@ -1,5 +1,5 @@
import { LogoWrapper } from '@/components/LogoWrapper';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import Image from 'next/image';
const BOKEH_ORBS = [ const BOKEH_ORBS = [
'top-[20%] left-[10%] size-[min(18vw,140px)] opacity-[0.14]', 'top-[20%] left-[10%] size-[min(18vw,140px)] opacity-[0.14]',
@@ -82,24 +82,21 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
/> />
))} ))}
<div className="absolute inset-x-10 top-1/2 -translate-y-1/2 text-center text-primary-foreground"> <Image
<div className="mb-9 inline-flex items-center gap-5 text-left"> src="/logo.svg"
<LogoWrapper alt=""
alt="" width={81}
className="size-28 rounded-lg border-[5px] p-4" height={58}
priority className="absolute left-10 top-12 h-auto w-16 object-contain"
variant="glass" priority
/> />
<span className="min-w-0">
<span className="block text-5xl font-semibold leading-none tracking-normal"> <div className="absolute bottom-10 left-10 right-10 text-left text-primary-foreground">
Road <p className="text-3xl leading-none tracking-tight">
</span> <span className="font-semibold">Road</span>
<span className="block text-5xl font-semibold leading-none tracking-normal"> <span className="font-normal">Monitor</span>
Monitor </p>
</span> <p className="mt-1 max-w-sm text-xs leading-4 text-primary-foreground/90">
</span>
</div>
<p className="mx-auto max-w-md text-base leading-7 text-primary-foreground/82">
Map, monitor, and repair road issues with data-driven surface Map, monitor, and repair road issues with data-driven surface
intelligence. intelligence.
</p> </p>
@@ -107,4 +104,3 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
</div> </div>
); );
} }

View File

@@ -30,9 +30,11 @@ export function TableFooter<TData>({
pageSize?: number; pageSize?: number;
onPageSizeChange?: (pageSize: number) => void; onPageSizeChange?: (pageSize: number) => void;
}) { }) {
const currentPageSize = pageSize ?? table.getState().pagination.pageSize; const paginationState = table.getState().pagination;
const currentPageSize =
pageSize ?? paginationState?.pageSize ?? PAGE_SIZE_OPTIONS[0];
const selectedRowCount = Object.keys(table.getState().rowSelection).length; const selectedRowCount = Object.keys(table.getState().rowSelection).length;
const pageIndex = table.getState().pagination.pageIndex; const pageIndex = paginationState?.pageIndex ?? 0;
const rawPageCount = table.getPageCount(); const rawPageCount = table.getPageCount();
const pageCount = Math.max( const pageCount = Math.max(
1, 1,

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,10 +41,11 @@ 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;
hidden?: (item: TData) => boolean;
className?: string; className?: string;
} }
@@ -156,10 +157,16 @@ function DataTableContent<TData, TValue>({
return ( return (
<div className="flex justify-start gap-2"> <div className="flex justify-start gap-2">
{actions.map((action) => { {actions.map((action) => {
if (action.hidden?.(item)) return null;
const label = const label =
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
@@ -173,7 +180,7 @@ function DataTableContent<TData, TValue>({
action.onClick(item); action.onClick(item);
}} }}
> >
{action.icon} {icon}
</TableActionButton> </TableActionButton>
); );
})} })}
@@ -212,37 +219,41 @@ function DataTableContent<TData, TValue>({
manualSorting: isManualSorting, manualSorting: isManualSorting,
pageCount: pagination pageCount: pagination
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit) ? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
: -1, : undefined,
state: { state: {
rowSelection, rowSelection,
columnVisibility, columnVisibility,
columnPinning, columnPinning,
columnSizing, columnSizing,
sorting, sorting,
pagination: pagination ...(pagination
? { ? {
pagination: {
pageIndex: Math.floor(pagination.skip / pagination.limit),
pageSize: pagination.limit,
},
}
: {}),
},
onPaginationChange: pagination
? (updater) => {
const currentState = {
pageIndex: Math.floor(pagination.skip / pagination.limit), pageIndex: Math.floor(pagination.skip / pagination.limit),
pageSize: pagination.limit, pageSize: pagination.limit,
};
const newState =
typeof updater === 'function' ? updater(currentState) : updater;
const pageSizeChanged = newState.pageSize !== pagination.limit;
if (pageSizeChanged) {
pagination.onLimitChange(newState.pageSize);
pagination.onPageChange(0);
return;
} }
: undefined,
},
onPaginationChange: (updater) => {
if (typeof updater === 'function' && pagination) {
const newState = updater({
pageIndex: Math.floor(pagination.skip / pagination.limit),
pageSize: pagination.limit,
});
const pageSizeChanged = newState.pageSize !== pagination.limit;
if (pageSizeChanged) { pagination.onPageChange(newState.pageIndex * newState.pageSize);
pagination.onLimitChange(newState.pageSize);
pagination.onPageChange(0);
return;
} }
: undefined,
pagination.onPageChange(newState.pageIndex * newState.pageSize);
}
},
}); });
return ( return (

View File

@@ -27,6 +27,7 @@ interface MultiSelectPopoverProps {
onValuesChange: (values: string[]) => void; onValuesChange: (values: string[]) => void;
searchPlaceholder?: string; searchPlaceholder?: string;
emptyMessage?: string; emptyMessage?: string;
emptySelectionLabel?: string;
align?: 'start' | 'center' | 'end'; align?: 'start' | 'center' | 'end';
} }
@@ -45,6 +46,7 @@ export function MultiSelectPopover({
onValuesChange, onValuesChange,
searchPlaceholder = 'Search', searchPlaceholder = 'Search',
emptyMessage = 'No options found.', emptyMessage = 'No options found.',
emptySelectionLabel,
align = 'end', align = 'end',
}: MultiSelectPopoverProps) { }: MultiSelectPopoverProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -96,7 +98,11 @@ export function MultiSelectPopover({
> >
{icon} {icon}
{label} {label}
<Badge variant="secondary">{values.length}</Badge> <Badge variant="secondary">
{values.length === 0 && emptySelectionLabel
? emptySelectionLabel
: values.length}
</Badge>
<ChevronDown /> <ChevronDown />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -7,11 +7,12 @@ import {
CreditCard, CreditCard,
LogOut, LogOut,
Sparkles, Sparkles,
UserRound,
} from 'lucide-react'; } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -39,7 +40,6 @@ export function NavUser({
user: { user: {
name: string; name: string;
email: string; email: string;
avatar: string;
}; };
}) { }) {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
@@ -76,9 +76,10 @@ export function NavUser({
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8">
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarFallback>
<AvatarFallback className="rounded-lg">CN</AvatarFallback> <UserRound className="size-4" aria-hidden="true" />
</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span> <span className="truncate font-semibold">{user.name}</span>
@@ -95,9 +96,10 @@ export function NavUser({
> >
<DropdownMenuLabel className="p-0 font-normal"> <DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8">
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarFallback>
<AvatarFallback className="rounded-lg">CN</AvatarFallback> <UserRound className="size-4" aria-hidden="true" />
</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span> <span className="truncate font-semibold">{user.name}</span>

View File

@@ -12,7 +12,7 @@ function Card({
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-lg bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-lg *:[img:last-child]:rounded-lg", "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-lg bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-inset ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-lg *:[img:last-child]:rounded-lg",
className className
)} )}
{...props} {...props}

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

@@ -9,6 +9,7 @@ import {
Users, Users,
Building2, Building2,
BadgeIndianRupee, BadgeIndianRupee,
CalendarClock,
Globe, Globe,
} from 'lucide-react'; } from 'lucide-react';
import type { ComponentType, SVGProps } from 'react'; import type { ComponentType, SVGProps } from 'react';
@@ -37,6 +38,12 @@ export const menuItems: MenuItem[] = [
icon: Ticket, icon: Ticket,
permission: PERMISSIONS.TICKET.READ, permission: PERMISSIONS.TICKET.READ,
}, },
{
title: 'Extension Requests',
path: ROUTES.EXTENSION_REQUESTS,
icon: CalendarClock,
permission: PERMISSIONS.TICKET.ASSIGN,
},
{ {
title: 'Project', title: 'Project',
path: ROUTES.PROJECT, path: ROUTES.PROJECT,

View File

@@ -63,14 +63,16 @@ 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}`,
CLASS_DETAIL: (id: string, defectClass: string) => ASSIGNMENT_DETECTIONS: (id: string) =>
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`, `/biz/api/v1/tickets/${id}/assignment-detections`,
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`, TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`, ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) => REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`, `/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
EXTENSION_REQUESTS: (ticketId: string) => EXTENSION_REQUESTS: '/biz/api/v1/tickets/extension-requests',
TICKET_EXTENSION_REQUESTS: (ticketId: string) =>
`/biz/api/v1/tickets/${ticketId}/extension-requests`, `/biz/api/v1/tickets/${ticketId}/extension-requests`,
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) => REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`, `/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`,

View File

@@ -1,35 +0,0 @@
import type { TicketDefectClassTicketStatus } from '@/types';
export const DEFECT_CLASS_STATUS_CONFIG: Record<
TicketDefectClassTicketStatus,
{
label: string;
badgeClassName: string;
}
> = {
unassigned: {
label: 'Unassigned',
badgeClassName:
'bg-zinc-500/15 text-zinc-600 dark:bg-zinc-500/20 dark:text-zinc-300',
},
assigned: {
label: 'Assigned',
badgeClassName:
'bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400',
},
under_review: {
label: 'Under Review',
badgeClassName:
'bg-violet-500/15 text-violet-600 dark:bg-violet-500/20 dark:text-violet-400',
},
approved: {
label: 'Approved',
badgeClassName:
'bg-emerald-500/15 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400',
},
rejected: {
label: 'Rejected',
badgeClassName:
'bg-orange-500/15 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400',
},
};

View File

@@ -8,14 +8,28 @@ import type {
RequestTicketExtensionPayload, RequestTicketExtensionPayload,
ReviewTicketExtensionPayload, ReviewTicketExtensionPayload,
SseTokenResponse, SseTokenResponse,
TicketDetail, TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketAssignmentDetectionsResponse,
TicketAssignmentsResponse,
TicketExtensionRequestsResponse, TicketExtensionRequestsResponse,
TicketExtensionRequestsParams,
TicketOverviewDetail, TicketOverviewDetail,
TicketDefectClassesResponse,
TicketListParams, TicketListParams,
TicketListResponse, TicketListResponse,
TicketSummaryParams,
TicketSummaryResponse,
TicketTimelineResponse,
} from '@/types'; } from '@/types';
type TicketAssignmentsApiResponse =
| TicketAssignmentSummary[]
| TicketAssignmentsResponse
| {
assignments: TicketAssignmentSummary[];
total?: number;
};
export const ticketService = { export const ticketService = {
getTickets: async ( getTickets: async (
params?: TicketListParams, params?: TicketListParams,
@@ -33,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> => {
@@ -42,21 +70,32 @@ export const ticketService = {
return response.data; return response.data;
}, },
getTicketClassDetail: async ( getTicketAssignmentDetections: async (
ticketId: string, ticketId: string,
defectClass: string, params?: TicketAssignmentDetectionsParams,
): Promise<TicketDetail> => { ): Promise<TicketAssignmentDetectionsResponse> => {
const response = await axiosClient.get<TicketDetail>( const response = await axiosClient.get<TicketAssignmentDetectionsResponse>(
API_ROUTES.TICKETS.CLASS_DETAIL(ticketId, defectClass), 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; return response.data;
}, },
getTicketDefectClasses: async ( getTicketTimeline: async (
ticketId: string, ticketId: string,
): Promise<TicketDefectClassesResponse> => { ): Promise<TicketTimelineResponse> => {
const response = await axiosClient.get<TicketDefectClassesResponse>( const response = await axiosClient.get<TicketTimelineResponse>(
API_ROUTES.TICKETS.DEFECT_CLASSES(ticketId), API_ROUTES.TICKETS.TIMELINE(ticketId),
); );
return response.data; return response.data;
}, },
@@ -80,14 +119,36 @@ export const ticketService = {
assignTicket: async ( assignTicket: async (
ticketId: string, ticketId: string,
payload: AssignTicketPayload, payload: AssignTicketPayload,
): Promise<TicketDetail> => { ): Promise<TicketAssignmentSummary> => {
const response = await axiosClient.post<TicketDetail>( const response = await axiosClient.post<TicketAssignmentSummary>(
API_ROUTES.TICKETS.ASSIGN(ticketId), API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
payload, payload,
); );
return response.data; return response.data;
}, },
getTicketAssignments: async (
ticketId: string,
): Promise<TicketAssignmentsResponse> => {
const response = await axiosClient.get<TicketAssignmentsApiResponse>(
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
);
const data = response.data;
if (Array.isArray(data)) {
return { items: data, total: data.length };
}
if ('assignments' in data) {
return {
items: data.assignments,
total: data.total ?? data.assignments.length,
};
}
return data;
},
requestTicketExtension: async ( requestTicketExtension: async (
ticketId: string, ticketId: string,
assignmentId: number, assignmentId: number,
@@ -105,7 +166,7 @@ export const ticketService = {
assignmentId: number, assignmentId: number,
): Promise<TicketExtensionRequestsResponse> => { ): Promise<TicketExtensionRequestsResponse> => {
const response = await axiosClient.get<TicketExtensionRequestsResponse>( const response = await axiosClient.get<TicketExtensionRequestsResponse>(
API_ROUTES.TICKETS.EXTENSION_REQUESTS(ticketId), API_ROUTES.TICKETS.TICKET_EXTENSION_REQUESTS(ticketId),
{ {
params: { assignment_id: assignmentId }, params: { assignment_id: assignmentId },
}, },
@@ -113,6 +174,16 @@ export const ticketService = {
return response.data; return response.data;
}, },
getTicketExtensionRequests: async (
params: TicketExtensionRequestsParams,
): Promise<TicketExtensionRequestsResponse> => {
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
API_ROUTES.TICKETS.EXTENSION_REQUESTS,
{ params },
);
return response.data;
},
reviewTicketExtension: async ( reviewTicketExtension: async (
ticketId: string, ticketId: string,
assignmentId: number, assignmentId: number,
@@ -124,11 +195,12 @@ export const ticketService = {
); );
return response.data; return response.data;
}, },
closeTicket: async ( closeTicket: async (
ticketId: string, ticketId: string,
payload: CloseTicketPayload, payload: CloseTicketPayload,
): Promise<TicketDetail> => { ): Promise<unknown> => {
const response = await axiosClient.post<TicketDetail>( const response = await axiosClient.post<unknown>(
API_ROUTES.TICKETS.CLOSE(ticketId), API_ROUTES.TICKETS.CLOSE(ticketId),
payload, payload,
); );

View File

@@ -73,12 +73,17 @@ export const videoService = {
params: { params: {
skip: params?.skip ?? 0, skip: params?.skip ?? 0,
limit: params?.limit ?? 1, limit: params?.limit ?? 1,
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,
sort: params?.sort ?? 'timestamp_asc', sort: params?.sort ?? 'timestamp_asc',
search: params?.search, search: params?.search,
}, },
paramsSerializer: {
indexes: null,
},
}, },
); );
return response.data; return response.data;

View File

@@ -75,9 +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_status?: AssignmentDetectionStatus;
class_name?: string | string[]; class_name?: string | string[];
proof_status?: DetectionProofStatus; proof_status?: DetectionProofStatus;
min_confidence?: number; min_confidence?: number;
@@ -103,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

@@ -22,11 +22,21 @@ export interface AssignableTicketUsersResponse {
total: number; total: number;
} }
export interface TicketAssignmentCriteria {
class_names?: string[] | null;
start_lat?: number | null;
start_lng?: number | null;
end_lat?: number | null;
end_lng?: number | null;
min_confidence?: number | null;
}
export interface AssignTicketPayload { export interface AssignTicketPayload {
assigned_to_user_id: number; assigned_to_user_id: number;
defect_class: string; assigned_to_email: string;
due_at: string; due_at: string;
note?: string; note?: string;
criteria?: TicketAssignmentCriteria;
} }
export interface RequestTicketExtensionPayload { export interface RequestTicketExtensionPayload {
@@ -36,7 +46,7 @@ export interface RequestTicketExtensionPayload {
export interface ReviewTicketExtensionPayload { export interface ReviewTicketExtensionPayload {
action: 'approve' | 'reject'; action: 'approve' | 'reject';
note: string; note?: string;
} }
export interface TicketExtensionRequestActor { export interface TicketExtensionRequestActor {
@@ -48,19 +58,33 @@ export interface TicketExtensionRequestActor {
}; };
} }
export type TicketExtensionRequestStatus =
| 'pending'
| 'approved'
| 'rejected'
| 'cancelled';
export interface TicketExtensionRequest { export interface TicketExtensionRequest {
id: number; id: number;
ticket_id: string; ticket_id: string;
ticket_name: string;
assignment_id: number; assignment_id: number;
defect_class: string; defect_class?: string | null;
current_due_at: string; current_due_at: string;
requested_due_at: string; requested_due_at: string;
reason: string; reason: string;
status: 'pending' | 'approved' | 'rejected' | 'cancelled'; status: TicketExtensionRequestStatus;
requested_by: TicketExtensionRequestActor | null; requested_by: TicketExtensionRequestActor | null;
requested_at: string; requested_at: string;
resolved_by: TicketExtensionRequestActor | null; resolved_by: TicketExtensionRequestActor | null;
resolved_at: string | null; resolved_at: string | null;
decision_note: string | null;
history_meta: {
previous_request_count: number;
previous_approved_count: number;
previous_rejected_count: number;
previous_cancelled_count: number;
};
} }
export interface TicketExtensionRequestsResponse { export interface TicketExtensionRequestsResponse {
@@ -68,6 +92,11 @@ export interface TicketExtensionRequestsResponse {
total: number; total: number;
} }
export interface TicketExtensionRequestsParams extends PaginationParams {
ticket_name?: string;
status?: TicketExtensionRequestStatus;
}
export interface CloseTicketPayload { export interface CloseTicketPayload {
defect_class: string; defect_class: string;
} }

View File

@@ -1,19 +0,0 @@
export type TicketDefectClassTicketStatus =
| 'unassigned'
| 'assigned'
| 'under_review'
| 'approved'
| 'rejected';
export interface TicketDefectClassItem {
class_name: string;
display_name: string;
assignment_status: TicketDefectClassTicketStatus;
}
export interface TicketDefectClassesResponse {
ticket_id: string;
video_id: string;
ticket_status: TicketDefectClassTicketStatus;
defect_classes: TicketDefectClassItem[];
}

View File

@@ -1,5 +1,4 @@
import type { TicketDefectClassTicketStatus } from './defect-class'; import type { TicketAssignmentCriteria } from './actions';
import type { TicketStatus } from './status';
export interface TicketActor { export interface TicketActor {
user_id: number | null; user_id: number | null;
@@ -8,38 +7,6 @@ export interface TicketActor {
avatar_url?: string | null; avatar_url?: string | null;
} }
export type TicketHistorySource = 'system' | 'user';
export interface TicketHistoryNote {
kind: string;
author: TicketActor | null;
text: string;
}
export interface TicketHistoryItem {
id: number;
event_type: string;
title: string;
source: TicketHistorySource;
assignment_id: number | null;
defect_class: string | null;
from_class_status: TicketDefectClassTicketStatus | null;
to_class_status: TicketDefectClassTicketStatus | null;
actor: TicketActor | null;
target_user: TicketActor | null;
note: TicketHistoryNote | null;
created_at: string;
}
export interface TicketStatusMetadata {
reason: string | null;
label: string | null;
message: string | null;
detection_count?: number | null;
requires_action?: boolean;
review_comment?: string | null;
}
export interface TicketVideo { export interface TicketVideo {
id: string; id: string;
name: string | null; name: string | null;
@@ -48,40 +15,6 @@ export interface TicketVideo {
thumbnail: string | null; thumbnail: string | null;
} }
export interface TicketWorker extends TicketActor {
assignment_id: number;
assigned_at: string | null;
due_at: string | null;
assigned_by_user_id: number | null;
assigned_by_name: string | null;
role: {
id: number;
name: string;
} | null;
}
export interface TicketRepair {
notes: string | null;
proof_video_url: string | null;
proof_image_urls: string[];
submitted_at: string | null;
}
export interface TicketReviewer extends TicketActor {
review_comment: string | null;
reviewed_at: string | null;
}
export interface TicketAiResult {
detection_count: number;
available_defect_classes: string[];
}
export interface TicketTenant {
root_tenant_id: number;
organization_id: number;
}
export interface TicketTimestamps { export interface TicketTimestamps {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -114,9 +47,34 @@ export interface TicketDetectionClassCount {
count: number; count: number;
} }
export interface TicketAssignmentSummary {
id: number;
assigned_to_user_id: number;
assigned_to_name: string | null;
assigned_to_email?: string | null;
assigned_to_avatar_url?: string | null;
defect_class: string | null;
criteria: TicketAssignmentCriteria | null;
item_count: number;
approved_detections: number;
pending_detections: number;
not_submitted_detections: number;
is_complete: boolean;
is_overdue: boolean;
due_at: string | null;
note?: string | null;
}
export interface TicketAssignmentsResponse {
items: TicketAssignmentSummary[];
total: number;
}
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;
@@ -134,27 +92,6 @@ export interface TicketOverviewDetail {
available_defect_classes: string[]; available_defect_classes: string[];
detections_by_class: TicketDetectionClassCount[]; detections_by_class: TicketDetectionClassCount[];
}; };
assignments?: TicketAssignmentSummary[];
timestamps?: TicketTimestamps | null; timestamps?: TicketTimestamps | null;
} }
export interface TicketDetail {
id: string;
ticket_name?: string;
video_id: string;
chainage_id: string | null;
chainage_name: string | null;
status: TicketStatus;
defect_class: string;
defect_display_name: string;
assignment_status: TicketDefectClassTicketStatus;
status_metadata: TicketStatusMetadata | null;
video: TicketVideo | null;
uploader: TicketActor | null;
worker: TicketWorker | null;
repair: TicketRepair | null;
reviewer: TicketReviewer | null;
ai_result: TicketAiResult | null;
tenant: TicketTenant | null;
timestamps: TicketTimestamps;
history: TicketHistoryItem[];
}

View File

@@ -1,5 +1,5 @@
import type { TicketDetail } from './detail';
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;
@@ -15,14 +15,16 @@ 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;
}; };
export type TicketDetailStatusEvent = Partial<TicketDetail> & { export type TicketDetailStatusEvent = {
type: 'ticket_status' | 'heartbeat'; type: 'ticket_status' | 'heartbeat';
kind?: 'snapshot' | 'transitioned'; kind?: 'snapshot' | 'transitioned';
id?: string; id?: string;

View File

@@ -2,5 +2,6 @@ export * from './status';
export * from './list'; export * from './list';
export * from './detail'; export * from './detail';
export * from './actions'; export * from './actions';
export * from './defect-class';
export * from './events'; export * from './events';
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;
}

View File

@@ -0,0 +1,60 @@
export type UTCDateTimeString = string;
export type TicketTimelineSource = 'system' | 'user';
export type TicketTimelineNoteKind =
| 'system_note'
| 'user_note'
| 'repair_note'
| 'review_comment';
export type TicketTimelineEventType =
| 'ai_detected'
| 'ticket_created'
| 'ticket_fully_assigned'
| 'ticket_completed'
| 'ticket_closed'
| 'assignment_added'
| 'assignment_replaced'
| 'assignment_updated'
| 'assignment_deleted'
| 'repair_started'
| 'repair_submitted'
| 'assignment_approve'
| 'assignment_reject'
| 'extension_requested'
| 'extension_approved'
| 'extension_rejected'
| 'extension_cancelled'
| 'lot_submitted_for_review'
| 'lot_resubmitted_for_review'
| 'lot_returned_for_correction'
| 'lot_approved';
export interface TicketTimelineUser {
user_id: number | null;
name: string | null;
email: string | null;
}
export interface TicketTimelineNote {
kind: TicketTimelineNoteKind;
author: TicketTimelineUser;
text: string;
}
export interface TicketTimelineItem {
id: number;
event_type: TicketTimelineEventType | null;
title: string;
source: TicketTimelineSource;
assignment_id: number | null;
actor: TicketTimelineUser;
target_user: TicketTimelineUser | null;
note: TicketTimelineNote | null;
created_at: UTCDateTimeString;
}
export interface TicketTimelineResponse {
history: TicketTimelineItem[];
}

View File

@@ -15,7 +15,8 @@ export const ROUTES = {
ACCOUNT: '/account', ACCOUNT: '/account',
UPLOAD: '/upload', UPLOAD: '/upload',
TICKET: '/ticket', TICKET: '/ticket',
EXTENSION_REQUESTS: '/extension-requests',
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`, TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
TICKET_CLASS_REVIEW: (ticketId: string, defectClass: string) => TICKET_ASSIGN: (ticketId: string) => `/ticket/${ticketId}/assign`,
`/ticket/${ticketId}/${defectClass}/review`, TICKET_REVIEW: (ticketId: string) => `/ticket/${ticketId}/review`,
} as const; } as const;