37 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
82 changed files with 4627 additions and 1269 deletions

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 (
<AuthGuard>
<SidebarProvider>
<SidebarProvider defaultOpen={false}>
<AppSidebar />
<main className="flex flex-1 h-screen w-full flex-col overflow-hidden">
<div className="scroll-stable flex-1 overflow-auto">
<div className="mx-auto flex min-h-full w-full max-w-380 flex-col">
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
<div className="scroll-stable min-w-0 flex-1 overflow-auto">
<div className="mx-auto flex min-h-full w-full min-w-0 max-w-380 flex-col">
<ModuleShellHeader />
<div className="flex-1 p-6">{children}</div>
<div className="min-w-0 flex-1 p-6">{children}</div>
</div>
</div>
</main>

View File

@@ -2,7 +2,7 @@
import type { ColumnDef, SortingState } from '@tanstack/react-table';
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 { PERMISSIONS } from '@/constants/permissions';
@@ -58,7 +58,12 @@ export function RoleTable({
},
{
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,
disabled: () => isStatusPending,
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 { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({
ticket,
backHref,
}: {
ticket: TicketOverviewDetail;
backHref: string;
}) {
const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id;
const ticketName = ticket.ticket_name || 'Unnamed ticket';
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1>
<p className="text-xs ">Ticket: {ticketLabel}</p>
<div className="flex min-w-0 items-start gap-3">
<Button
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 className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
onClick={() => router.push('/ticket')}
>
<ArrowLeft className="size-4" />
Back to List
</Button>
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
<OpenRepairReviewAction
ticketId={ticket.id}
hasPendingReview={ticket.has_pending_review}
pendingReviewCount={ticket.pending_review_count}
/>
</PermissionGuard>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} />
</PermissionGuard>
</div>
</div>
);

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
@@ -13,18 +12,20 @@ import { cn } from '@/lib/utils';
import {
useDiscardDetectionMutation,
useTicketClassDetectionsQuery,
useTicketDetectionsQuery,
} from '../../hooks/useTicketQueries';
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketClassDetectionPreviewProps {
interface TicketDetectionPreviewProps {
ticketId: string;
videoId?: string | null;
defectClassName: string;
displayName: string;
totalCount?: number;
assignmentId?: number;
assignmentStatus?: 'unassigned';
onDetectionCountChange?: (count: number | undefined) => void;
}
const DETECTION_PAGE_SIZE = 10;
function DetectionMetadataBar({
items,
}: {
@@ -54,7 +55,7 @@ function DetectionMetadataBar({
);
}
function TicketClassDetectionPreviewSkeleton() {
function TicketDetectionPreviewSkeleton() {
return (
<>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
@@ -93,58 +94,86 @@ function TicketClassDetectionPreviewSkeleton() {
);
}
export function TicketClassDetectionPreview({
export function TicketDetectionPreview({
ticketId,
videoId,
defectClassName,
displayName,
totalCount,
}: TicketClassDetectionPreviewProps) {
assignmentId,
assignmentStatus,
onDetectionCountChange,
}: TicketDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon;
const isUnassignedPreview = assignmentStatus === 'unassigned';
const pageStart =
Math.floor(currentIndex / DETECTION_PAGE_SIZE) * DETECTION_PAGE_SIZE;
useEffect(() => {
setCurrentIndex(0);
}, [defectClassName]);
}, [assignmentId, assignmentStatus, videoId]);
const queryParams = useMemo(
() => ({
class_name: defectClassName,
skip: currentIndex,
limit: 1,
skip: pageStart,
limit: DETECTION_PAGE_SIZE,
assignment_id: isUnassignedPreview ? undefined : assignmentId,
assignment_status: isUnassignedPreview
? ('unassigned' as const)
: undefined,
sort: 'timestamp_asc',
}),
[currentIndex, defectClassName],
[assignmentId, isUnassignedPreview, pageStart],
);
const detectionsQuery = useTicketClassDetectionsQuery(
const detectionsQuery = useTicketDetectionsQuery(
videoId ?? undefined,
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(
ticketId,
videoId ?? undefined,
defectClassName,
);
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
useEffect(() => {
if (detectionCount === 0 && currentIndex !== 0) {
if (confirmedDetectionCount === undefined) return;
if (confirmedDetectionCount === 0 && currentIndex !== 0) {
setCurrentIndex(0);
return;
}
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
setCurrentIndex(detectionCount - 1);
if (
confirmedDetectionCount > 0 &&
currentIndex > confirmedDetectionCount - 1
) {
setCurrentIndex(confirmedDetectionCount - 1);
}
}, [currentIndex, detectionCount]);
}, [confirmedDetectionCount, currentIndex]);
useEffect(() => {
onDetectionCountChange?.(detectionCount);
}, [detectionCount, onDetectionCountChange]);
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 (
<div className="space-y-4">
@@ -153,28 +182,15 @@ export function TicketClassDetectionPreview({
<span
className={cn(
'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>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<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 className="min-w-0">
<h3>{activeDisplayName}</h3>
</div>
</div>
@@ -215,28 +231,27 @@ export function TicketClassDetectionPreview({
</div>
</div>
{detectionsQuery.isLoading && !detectionsQuery.data ? (
<TicketClassDetectionPreviewSkeleton />
) : detectionsQuery.isError ? (
{isInitialLoading ? (
<TicketDetectionPreviewSkeleton />
) : isError ? (
<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">
<AlertTriangle className="size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load detection preview.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void detectionsQuery.refetch()}
>
<Button type="button" variant="outline" size="sm" onClick={retry}>
Retry
</Button>
</div>
</div>
) : !activeDetection || !detectionResult ? (
<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>
) : (
<>
@@ -258,7 +273,7 @@ export function TicketClassDetectionPreview({
className="min-w-0"
latitude={activeDetection.location.latitude}
longitude={activeDetection.location.longitude}
label={`${displayName} - Frame ${activeDetection.frame.number}`}
label={`${activeDetection.detection.display_name} - Frame ${activeDetection.frame.number}`}
title="Location on Map"
variant="plain"
aspectRatio="16 / 9"
@@ -283,30 +298,34 @@ export function TicketClassDetectionPreview({
]}
/>
<div className="flex justify-end">
<Button
type="button"
variant="destructive"
onClick={() => setIsDiscardDialogOpen(true)}
>
<Trash2 />
Discard detection
</Button>
</div>
{isUnassignedPreview ? (
<>
<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);
}}
/>
<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}
</>
)}
</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';
import { History, MessageSquareText } from 'lucide-react';
import { History, MessageSquareText, RefreshCw } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Timeline, TimelineItem } from '@/components/ui/timeline';
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 { getPersonLabel } from '@/utils/person';
import { useTicketTimelineQuery } from '../../hooks/useTicketQueries';
import { TicketTimelineSkeleton } from './TicketTimelineSkeleton';
function TimelineDate({ value }: { value: string }) {
const { date, time } = formatTimelineDateTime(value);
return (
<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>
</time>
);
}
function HistoryNote({ item }: { item: TicketHistoryItem }) {
const noteText = item.note?.text?.trim();
function getNoteKindLabel(kind: TicketTimelineNoteKind) {
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;
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">
<MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
<span>
Note by {getPersonLabel(item.note?.author ?? item.actor)}
{getNoteKindLabel(note.kind)} by {getPersonLabel(note.author)}
</span>
</div>
<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';
}
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
function SystemHistoryEntry({ item }: { item: TicketTimelineItem }) {
const noteText = item.note?.text?.trim();
return (
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
<Badge
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
>
<Badge className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
{getSourceLabel(item.source)}
</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 ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{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 isAssigned = item.event_type === 'assigned';
const showTargetUser =
item.target_user && !isSameUser(item.actor, item.target_user);
return (
<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} />
{isAssigned && item.target_user ? (
{showTargetUser && item.target_user ? (
<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} />
</div>
) : null}
@@ -84,17 +146,54 @@ function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
);
}
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
const history = ticket.history ?? [];
function TimelineHeader() {
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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="size-5 text-primary" />
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
</div>
<TimelineHeader />
{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>
{history.map((item, index) => (
<TimelineItem

View File

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

View File

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

View File

@@ -1,75 +1,102 @@
'use client';
import { AlertTriangle } from 'lucide-react';
import { PERMISSIONS } from '@/constants/permissions';
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 { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
interface TicketStatusActionsProps {
ticket: TicketDetail;
ticketId: string;
assignment?: TicketAssignmentSummary;
}
function getTicketAction(ticket: TicketDetail) {
if (ticket.assignment_status === 'unassigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />}
>
<AssignTicketAction ticket={ticket} />
</PermissionGuard>
);
}
function OverdueLotAlert({
lotId,
dueAt,
}: {
lotId: number;
dueAt: string | null;
}) {
return (
<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 (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={
<PermissionGuard
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>
}
>
<>
<AssignedContractorAction ticket={ticket} />
<ReviewExtensionRequestAction ticket={ticket} />
</>
<ReviewExtensionRequestAction
ticketId={ticketId}
assignmentId={assignment.id}
canReview={!assignment.is_complete}
/>
</PermissionGuard>
);
}
if (ticket.assignment_status === 'under_review') {
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 />;
return null;
}
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return <div className="space-y-3">{getTicketAction(ticket)}</div>;
export function TicketStatusActions({
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 { Timeline, TimelineItem } from '@/components/ui/timeline';
@@ -49,9 +48,9 @@ function TimelineEntrySkeleton({
);
}
export function TicketClassContentSkeleton() {
export function TicketTimelineSkeleton() {
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">
<Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-56 max-w-full" />
@@ -69,29 +68,3 @@ export function TicketClassContentSkeleton() {
</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';
import { useMemo, useState } from 'react';
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
import { Users } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import type { AssignableTicketUser, TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard';
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],
);
export function AssignTicketAction({ ticketId }: { ticketId: string }) {
const router = useRouter();
return (
<TicketActionCard icon={Users} title="Assign to Contractor">
<div className="space-y-4">
<div className="space-y-2">
<Label>Contractor *</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-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>
<Button
type="button"
onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
>
<Users />
Assign Ticket
</Button>
);
}

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

View File

@@ -1,14 +1,31 @@
import { Clock3 } from 'lucide-react';
import type { TicketDetail } from '@/types';
import { TicketActionCard } from './TicketActionCard';
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 (
<TicketActionCard icon={Clock3} title="Request Extension">
<TicketExtensionRequestPanel ticket={ticket} />
<TicketActionCard
icon={Clock3}
title={canRequest ? 'Request Extension' : 'Extension Request History'}
contextLabel={`Assignment ${assignmentId}`}
>
<TicketExtensionRequestPanel
ticketId={ticketId}
assignmentId={assignmentId}
dueAt={dueAt}
canRequest={canRequest}
/>
</TicketActionCard>
);
}

View File

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

View File

@@ -1,6 +1,5 @@
'use client';
import { useState } from 'react';
import {
ArrowRight,
CalendarClock,
@@ -10,24 +9,37 @@ import {
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 { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type {
TicketDetail,
TicketExtensionRequest,
TicketExtensionRequestsResponse,
} from '@/types';
import { formatDate } from '@/utils/date';
import {
useReviewTicketExtensionMutation,
useTicketExtensionRequestQuery,
} from '../../../hooks/useTicketQueries';
MAX_EXTENSION_DECISION_NOTE_LENGTH,
useExtensionRequestDecision,
} from '../../../../extension-requests/hooks/useExtensionRequestDecision';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
import {
ExtensionRequestDetails,
getLatestRequest,
} from './TicketExtensionRequestPanel';
import { TicketActionCard } from './TicketActionCard';
const MAX_NOTE_LENGTH = 300;
function getPendingRequest(response: TicketExtensionRequestsResponse) {
return response.items
.filter((request) => request.status === 'pending')
@@ -76,35 +88,35 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
}
function ExtensionReviewForm({
ticket,
ticketId,
assignmentId,
request,
requests,
}: {
ticket: TicketDetail;
ticketId: string;
assignmentId: number;
request: TicketExtensionRequest;
requests: TicketExtensionRequest[];
}) {
const [note, setNote] = useState('');
const [selectedAction, setSelectedAction] = useState<
'approve' | 'reject' | null
>(null);
const assignmentId = ticket.worker?.assignment_id as number;
const reviewMutation = useReviewTicketExtensionMutation(
ticket.id,
assignmentId,
ticket.defect_class,
);
const {
note,
setNote,
noteError,
selectedAction,
isPending,
isRejectConfirmationOpen,
setIsRejectConfirmationOpen,
submitDecision,
requestRejection,
} = useExtensionRequestDecision({ ticketId, assignmentId });
const extensionDays = getExtensionDays(request);
const submitDecision = async (action: 'approve' | 'reject') => {
setSelectedAction(action);
try {
await reviewMutation.mutateAsync({ action, note: note.trim() });
} finally {
setSelectedAction(null);
}
};
return (
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
<TicketActionCard
icon={CalendarClock}
title="Extension Request Details"
contextLabel={`Assignment ${assignmentId}`}
>
<div className="space-y-4">
<Requester request={request} />
@@ -141,25 +153,39 @@ function ExtensionReviewForm({
</section>
<div className="space-y-2">
<Label htmlFor="extension-manager-note">
Add Manager Comments{' '}
<span className="text-muted-foreground">(Optional)</span>
</Label>
<div className="space-y-0.5">
<Label htmlFor="extension-manager-note">Add Manager Comments</Label>
<p className="text-xs text-muted-foreground">
Required for rejection and optional for approval.
</p>
</div>
<div className="relative">
<Textarea
id="extension-manager-note"
value={note}
onChange={(event) =>
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH))
}
onChange={(event) => {
setNote(event.target.value);
}}
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"
/>
<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>
</div>
{noteError ? (
<p
id="extension-manager-note-error"
className="text-xs text-destructive"
>
{noteError}
</p>
) : null}
</div>
<div className="grid grid-cols-2 gap-3">
@@ -167,8 +193,8 @@ function ExtensionReviewForm({
type="button"
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
disabled={reviewMutation.isPending}
onClick={() => submitDecision('reject')}
disabled={isPending}
onClick={requestRejection}
>
{selectedAction === 'reject' ? (
<Loader2 className="animate-spin" />
@@ -179,8 +205,8 @@ function ExtensionReviewForm({
</Button>
<Button
type="button"
disabled={reviewMutation.isPending}
onClick={() => submitDecision('approve')}
disabled={isPending}
onClick={() => void submitDecision('approve')}
>
{selectedAction === 'approve' ? (
<Loader2 className="animate-spin" />
@@ -190,29 +216,63 @@ function ExtensionReviewForm({
Approve Extension
</Button>
</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>
</TicketActionCard>
);
}
export function ReviewExtensionRequestAction({
ticket,
ticketId,
assignmentId,
canReview,
}: {
ticket: TicketDetail;
ticketId: string;
assignmentId: number;
canReview: boolean;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
ticketId,
assignmentId,
);
if (!assignmentId || extensionRequestQuery.isError) {
if (extensionRequestQuery.isError) {
return null;
}
if (extensionRequestQuery.isLoading) {
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">
<Loader2 className="mr-2 size-4 animate-spin" />
<span className="text-sm">Checking for requests...</span>
@@ -225,7 +285,32 @@ export function ReviewExtensionRequestAction({
? getPendingRequest(extensionRequestQuery.data)
: 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 { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
@@ -9,6 +10,7 @@ interface TicketActionCardProps {
icon: LucideIcon;
iconClassName?: string;
title: string;
contextLabel?: string;
children: React.ReactNode;
}
@@ -16,15 +18,23 @@ export function TicketActionCard({
icon: Icon,
iconClassName,
title,
contextLabel,
children,
}: TicketActionCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Icon className={cn('text-primary', iconClassName)} />
{title}
</CardTitle>
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex min-w-0 items-center gap-2 text-base">
<Icon className={cn('shrink-0 text-primary', iconClassName)} />
<span className="truncate">{title}</span>
</CardTitle>
{contextLabel ? (
<Badge variant="default" className="shrink-0">
{contextLabel}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>

View File

@@ -1,12 +1,13 @@
'use client';
import { useState } from 'react';
import {
AlertCircle,
CalendarDays,
CheckCircle2,
Clock3,
History,
Loader2,
Plus,
RotateCcw,
XCircle,
} from 'lucide-react';
@@ -14,13 +15,13 @@ import {
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
TicketDetail,
TicketExtensionRequest,
TicketExtensionRequestsResponse,
} from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
import { RequestExtensionForm } from './RequestExtensionForm';
interface DetailRowProps {
@@ -45,21 +46,21 @@ function DetailRow({ label, value, children, className }: DetailRowProps) {
const statusConfig = {
pending: {
title: 'Request Pending',
description: 'Your extension request is waiting for manager approval.',
description: 'This extension request is waiting for manager approval.',
icon: Clock3,
className:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300',
},
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,
className:
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300',
},
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,
className:
'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>(
(latest, item) => {
if (!latest) return item;
@@ -99,10 +100,14 @@ function getExtensionDays(request: TicketExtensionRequest) {
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
}
function ExtensionRequestDetails({
export function ExtensionRequestDetails({
request,
requests,
onRequestAnother,
}: {
request: TicketExtensionRequest;
requests: TicketExtensionRequest[];
onRequestAnother?: () => void;
}) {
const config = statusConfig[request.status];
const StatusIcon = config.icon;
@@ -125,6 +130,10 @@ function ExtensionRequestDetails({
label="Requested On"
value={formatDate(request.requested_at)}
/>
<DetailRow
label="Requested By"
value={request.requested_by?.name ?? 'Assigned worker'}
/>
<DetailRow
label="Requested Due Date"
value={formatDate(request.requested_due_at)}
@@ -161,6 +170,12 @@ function ExtensionRequestDetails({
value={formatDate(request.requested_due_at)}
/>
) : null}
{request.decision_note ? (
<DetailRow
label="Manager Comments"
value={request.decision_note}
/>
) : null}
</div>
</section>
) : null}
@@ -177,33 +192,36 @@ function ExtensionRequestDetails({
</div>
</section>
<Button type="button" variant="outline" className="w-full">
<History />
View Approval History
</Button>
<div className="space-y-2">
<ExtensionRequestHistoryDialog requests={requests} />
{request.status !== 'pending' && onRequestAnother ? (
<Button type="button" className="w-full" onClick={onRequestAnother}>
<Plus />
Request Another Extension
</Button>
) : null}
</div>
</div>
);
}
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(
ticket.id,
ticketId,
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) {
return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
@@ -237,8 +255,50 @@ export function TicketExtensionRequestPanel({
: null;
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 +1,27 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
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 {
TicketOverviewCardSkeleton,
TicketOverviewHeaderSkeleton,
} from './components/TicketOverviewSkeleton';
import {
TicketClassActionsSkeleton,
TicketClassContentSkeleton,
} from './components/TicketClassDetailSkeleton';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
import { TicketOverviewCard } from './components/TicketOverviewCard';
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 {
useTicketClassDetailQuery,
useTicketAssignmentsQuery,
useTicketOverviewQuery,
} from '../hooks/useTicketQueries';
@@ -29,17 +29,34 @@ export default function TicketDetailPage() {
const router = useRouter();
const searchParams = useSearchParams();
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 classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data;
const classDetail = classDetailQuery.data;
const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading,
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
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) {
return (
@@ -48,7 +65,7 @@ export default function TicketDetailPage() {
<Button
variant="outline"
size="sm"
onClick={() => router.push('/ticket')}
onClick={() => router.push(ticketListHref)}
>
<ArrowLeft />
Back to Tickets
@@ -58,33 +75,48 @@ export default function TicketDetailPage() {
}
return (
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<div className="xl:shrink-0">
{overview ? (
<TicketDetailHeader ticket={overview} />
<TicketDetailHeader ticket={overview} backHref={ticketListHref} />
) : (
<TicketOverviewHeaderSkeleton />
)}
</div>
<div className="grid 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 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<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="min-w-0 space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{overview ? (
<TicketOverviewCard ticket={overview} />
) : (
<TicketOverviewCardSkeleton />
)}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} />
{overview ? (
<TicketAssignmentsCard
ticketId={ticketId}
videoId={overview.video_id ?? overview.video?.id}
initialAssignments={overview.assignments}
selectedAssignmentId={activeAssignmentId}
onAssignmentChange={setSelectedAssignmentId}
/>
) : null}
{overview ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<TicketUnassignedIssuesCard
ticketId={ticketId}
videoId={overview.video_id ?? overview.video?.id}
/>
</PermissionGuard>
) : null}
<TicketHistoryCard ticketId={ticketId} />
</div>
<div className="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}
{!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} />
<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">
{activeAssignment ? (
<TicketStatusActions
ticketId={ticketId}
assignment={activeAssignment}
/>
) : null}
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,9 +18,9 @@ import type { ReviewDetectionRepairProofPayload } from '@/types';
import {
useReviewDetectionRepairProofMutation,
useTicketClassReviewDetectionsQuery,
useTicketReviewDetectionsQuery,
useTicketOverviewQuery,
} from '../../../hooks/useTicketQueries';
} from '../../hooks/useTicketQueries';
import { IssueEvidencePanel } from './components/IssueEvidencePanel';
import { IssueReviewAction } from './components/IssueReviewAction';
import { IssueReviewHeader } from './components/IssueReviewHeader';
@@ -39,14 +39,11 @@ const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
'rejected',
];
export default function TicketClassReviewPage() {
export default function TicketReviewPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { ticketId, defectClass } = useParams() as {
ticketId: string;
defectClass: string;
};
const { ticketId } = useParams() as { ticketId: string };
const proofStatusParam = searchParams.get('proof_status');
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
proofStatusParam as ProofStatusFilter,
@@ -57,9 +54,8 @@ export default function TicketClassReviewPage() {
const overviewQuery = useTicketOverviewQuery(ticketId);
const ticket = overviewQuery.data;
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
const detectionsQuery = useTicketClassReviewDetectionsQuery(
const detectionsQuery = useTicketReviewDetectionsQuery(
videoId,
defectClass,
proofStatus === 'all' ? 'submitted' : proofStatus,
);
const detectionResult = detectionsQuery.data?.pages[0];
@@ -72,7 +68,6 @@ export default function TicketClassReviewPage() {
const reviewMutation = useReviewDetectionRepairProofMutation({
ticketId,
videoId,
defectClass,
});
const selectIssue = useCallback(
@@ -171,8 +166,7 @@ export default function TicketClassReviewPage() {
}, [detectionsQuery]);
const backToTicket = () => {
const params = new URLSearchParams({ defect_class: defectClass });
router.push(`${ROUTES.TICKET_DETAIL(ticketId)}?${params.toString()}`);
router.push(ROUTES.TICKET_DETAIL(ticketId));
};
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">
<AlertTriangle className="size-8 text-destructive" />
<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">
The review screen uses the detection API for its queue and
evidence.
@@ -233,7 +227,6 @@ export default function TicketClassReviewPage() {
<div className="space-y-5">
<IssueReviewHeader
ticketLabel={ticket.ticket_name || ticket.id}
defectLabel={defectClass}
currentIndex={0}
total={0}
proofStatus={proofStatus}
@@ -248,7 +241,7 @@ export default function TicketClassReviewPage() {
<div>
<p className="font-semibold">No detections found.</p>
<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>
</div>
</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">
<IssueReviewHeader
ticketLabel={ticket.ticket_name || ticket.id}
defectLabel={defectLabel}
currentIndex={activeIndex}
total={totalCount}
proofStatus={proofStatus}

View File

@@ -2,11 +2,48 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { CalendarClock, ClipboardCheck } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { TicketListItem } from '@/types';
import { formatDate } from '@/utils/date';
function TicketAttention({ ticket }: { ticket: TicketListItem }) {
const summary = ticket.attention_summary;
const reviewCount = summary?.pending_repair_review_count ?? 0;
const extensionCount = summary?.pending_extension_request_count ?? 0;
const showReview = reviewCount > 0;
const showExtension = extensionCount > 0;
if (!showReview && !showExtension) return null;
return (
<div className="flex flex-wrap items-center gap-1.5">
{showReview ? (
<Badge
className="gap-1.5 border-0 bg-orange-100 text-orange-700 shadow-none dark:bg-orange-950/60 dark:text-orange-300"
title={`${reviewCount} pending repair ${reviewCount === 1 ? 'review' : 'reviews'}`}
>
<ClipboardCheck aria-hidden="true" />
Review
<span className="font-semibold tabular-nums">{reviewCount}</span>
</Badge>
) : null}
{showExtension ? (
<Badge
className="gap-1.5 border-0 bg-blue-100 text-blue-700 shadow-none dark:bg-blue-950/60 dark:text-blue-300"
title={`${extensionCount} pending extension ${extensionCount === 1 ? 'request' : 'requests'}`}
>
<CalendarClock aria-hidden="true" />
Extension
<span className="font-semibold tabular-nums">+{extensionCount}</span>
</Badge>
) : null}
</div>
);
}
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
return useMemo(
() => [
@@ -29,6 +66,24 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
header: 'Detections',
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',
header: 'Uploaded By',

View File

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

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,
default_defect_class: event.default_defect_class ?? null,
detection_count: event.detection_count,
is_closed: event.is_closed ?? false,
created_by_name: event.created_by_name ?? null,
created_by_email: event.created_by_email ?? null,
created_at: event.created_at ?? event.updated_at,
updated_at: event.updated_at,
attention_summary: event.attention_summary ?? {
has_pending_extension_request: false,
pending_extension_request_count: 0,
has_pending_repair_review: false,
pending_repair_review_count: 0,
},
};
}
@@ -118,10 +125,12 @@ function mergeTicketListItem(
default_defect_class:
event.default_defect_class ?? current.default_defect_class,
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_email: event.created_by_email ?? current.created_by_email,
created_at: event.created_at ?? current.created_at,
updated_at: event.updated_at ?? current.updated_at,
attention_summary: event.attention_summary ?? current.attention_summary,
};
}
@@ -132,6 +141,8 @@ export function useTenantTicketTableEvents(enabled = true) {
const events = useMemo(
() => ({
ticket_status: (event: TicketTableStatusEvent) => {
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
queryClient
.getQueryCache()
.findAll({ queryKey: ticketKeys.lists() })

View File

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

View File

@@ -2,6 +2,7 @@
import { useMemo } from 'react';
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
@@ -16,17 +17,23 @@ import type {
DiscardDetectionPayload,
RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload,
ReviewTicketExtensionPayload,
TicketDetail,
TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketListParams,
DetectionProofStatus,
VideoDetectionsParams,
} from '@/types';
import { ticketKeys } from '../queries/ticketKeys';
import { extensionRequestKeys } from '../../extension-requests/queries/extensionRequestKeys';
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
interface TicketDetectionsQueryOptions {
enabled?: boolean;
keepPreviousPage?: boolean;
}
interface UseTicketsQueryParams {
skip: 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) {
return useQuery({
queryKey: ticketKeys.overview(ticketId ?? ''),
@@ -68,43 +89,53 @@ export function useTicketOverviewQuery(ticketId: string | undefined) {
});
}
export function useTicketClassDetailQuery(
ticketId: string | undefined,
defectClass: string | undefined,
) {
export function useTicketTimelineQuery(ticketId: string | undefined) {
return useQuery({
queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''),
queryFn: () =>
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),
queryKey: ticketKeys.timeline(ticketId ?? ''),
queryFn: () => ticketService.getTicketTimeline(ticketId as string),
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,
params: VideoDetectionsParams,
options: TicketDetectionsQueryOptions = {},
) {
const { enabled = true, keepPreviousPage = false } = options;
const queryParams = useMemo(
() => ({
skip: params.skip ?? 0,
limit: params.limit ?? 1,
assignment_id: params.assignment_id,
assignment_status: params.assignment_status,
class_name: params.class_name,
min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc',
search: params.search,
}),
[
params.assignment_id,
params.assignment_status,
params.class_name,
params.limit,
params.min_confidence,
@@ -118,26 +149,56 @@ export function useTicketClassDetectionsQuery(
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
queryFn: () =>
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,
defectClass: string | undefined,
proofStatus?: DetectionProofStatus,
) {
const pageSize = 20;
return useInfiniteQuery({
queryKey: ticketKeys.classReviewDetections(
videoId ?? '',
defectClass ?? '',
proofStatus,
),
queryKey: ticketKeys.reviewDetections(videoId ?? '', proofStatus),
queryFn: ({ pageParam }) =>
videoService.getVideoDetections(videoId as string, {
class_name: defectClass,
proof_status: proofStatus,
sort: 'timestamp_asc',
skip: pageParam,
@@ -148,14 +209,13 @@ export function useTicketClassReviewDetectionsQuery(
const nextSkip = lastPage.skip + lastPage.items.length;
return nextSkip < lastPage.total ? nextSkip : undefined;
},
enabled: Boolean(videoId && defectClass),
enabled: Boolean(videoId),
});
}
export function useDiscardDetectionMutation(
ticketId: string,
videoId: string | undefined,
defectClass: string,
) {
const queryClient = useQueryClient();
@@ -179,13 +239,14 @@ export function useDiscardDetectionMutation(
queryClient.invalidateQueries({
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.summaries() }),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
}),
]);
},
onError: () => toast.error('Failed to discard detection'),
@@ -195,11 +256,9 @@ export function useDiscardDetectionMutation(
export function useReviewDetectionRepairProofMutation({
ticketId,
videoId,
defectClass,
}: {
ticketId: string;
videoId: string | undefined;
defectClass: string;
}) {
const queryClient = useQueryClient();
@@ -224,16 +283,14 @@ export function useReviewDetectionRepairProofMutation({
queryKey: ticketKeys.classDetectionLists(videoId),
})
: Promise.resolve(),
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
]);
},
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) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: AssignTicketPayload) =>
ticketService.assignTicket(ticketId, payload),
onSuccess: (ticket, payload) => {
onSuccess: async () => {
toast.success('Ticket assigned');
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
await Promise.all([
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
}),
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'),
});
@@ -297,7 +352,6 @@ export function useAssignTicketMutation(ticketId: string) {
export function useRequestTicketExtensionMutation(
ticketId: string,
assignmentId: number | undefined,
defectClass: string,
) {
const queryClient = useQueryClient();
@@ -318,53 +372,39 @@ export function useRequestTicketExtensionMutation(
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
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.summaries() });
},
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) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CloseTicketPayload) =>
ticketService.closeTicket(ticketId, payload),
onSuccess: (ticket, payload) => {
onSuccess: () => {
toast.success('Ticket closed');
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
},
onError: () => toast.error('Failed to close ticket'),
});

View File

@@ -1,22 +1,64 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/page-header';
import { Ticket } from 'lucide-react';
import type { TicketListItem } from '@/types';
import { ROUTES } from '@/utils/routes';
import { TicketFilters } from './components/TicketFilters';
import { TicketSummaryCards } from './components/TicketSummaryCards';
import { TicketTable } from './components/TicketTable';
import { useTicketColumns } from './components/TicketColumns';
import { useTenantTicketTableEvents } from './hooks/useTenantTicketTableEvents';
import { useTicketsQuery } from './hooks/useTicketQueries';
import {
useTicketsQuery,
useTicketSummaryQuery,
} from './hooks/useTicketQueries';
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() {
const router = useRouter();
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(10);
const [segmentId, setSegmentId] = useState('');
const pathname = usePathname();
const searchParams = useSearchParams();
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();
@@ -25,25 +67,62 @@ export default function TicketPage() {
limit,
chainageId: segmentId,
});
const summaryQuery = useTicketSummaryQuery(segmentId);
const columns = useTicketColumns();
const tickets = ticketsQuery.data?.items ?? [];
const total = ticketsQuery.data?.total ?? 0;
const handleSegmentChange = useCallback((nextSegmentId: string) => {
setSegmentId(nextSegmentId);
setSkip(0);
}, []);
const handleSegmentChange = useCallback(
(nextSegmentId: string) => {
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(
(ticket: TicketListItem) => {
const defectClassQuery = ticket.default_defect_class
? `?defect_class=${encodeURIComponent(ticket.default_defect_class)}`
: '';
router.push(`/ticket/${ticket.id}${defectClassQuery}`);
const detailPath = ROUTES.TICKET_DETAIL(ticket.id);
router.push(
searchParamsString ? `${detailPath}?${searchParamsString}` : detailPath,
);
},
[router],
[router, searchParamsString],
);
const toolbar = useMemo(
@@ -51,6 +130,7 @@ export default function TicketPage() {
<TicketFilters
segmentId={segmentId}
onSegmentChange={handleSegmentChange}
onClear={() => handleSegmentChange('')}
/>
),
[handleSegmentChange, segmentId],
@@ -64,6 +144,12 @@ export default function TicketPage() {
icon={Ticket}
/>
<TicketSummaryCards
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
isError={summaryQuery.isError}
/>
<TicketTable
columns={columns}
tickets={tickets}
@@ -72,8 +158,8 @@ export default function TicketPage() {
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
onView={handleView}
/>
</main>

View File

@@ -1,31 +1,39 @@
import type { TicketListParams, VideoDetectionsParams } from '@/types';
import type {
TicketAssignmentDetectionsParams,
TicketListParams,
VideoDetectionsParams,
} from '@/types';
export const ticketKeys = {
all: ['tickets'] as const,
lists: () => [...ticketKeys.all, 'list'] as const,
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
summaries: () => [...ticketKeys.all, 'summary'] as const,
summary: (params: { chainage_id?: string }) =>
[...ticketKeys.summaries(), params] as const,
details: () => [...ticketKeys.all, 'detail'] as const,
overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'overview'] as const,
classDetail: (ticketId: string, defectClass: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
timeline: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'timeline'] as const,
classDetectionLists: (videoId: string) =>
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...ticketKeys.classDetectionLists(videoId), params] as const,
classReviewDetections: (
videoId: string,
defectClass: string,
proofStatus?: string,
) =>
reviewDetections: (videoId: string, proofStatus?: string) =>
[
...ticketKeys.classDetectionLists(videoId),
'class-review',
defectClass,
'review',
proofStatus ?? 'all',
] as const,
defectClasses: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
assignmentDetectionLists: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignment-detections'] as const,
assignmentDetections: (
ticketId: string,
params: TicketAssignmentDetectionsParams,
) => [...ticketKeys.assignmentDetectionLists(ticketId), params] as const,
assignments: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignments'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
[
...ticketKeys.details(),

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import type { ReactNode } from 'react';
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 { PERMISSIONS } from '@/constants/permissions';
@@ -59,7 +59,12 @@ export function UserTable({
{
label: (user) =>
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,
disabled: (user) =>
pendingUserId === user.id || user.effective_status === 'pending',

View File

@@ -1,13 +1,13 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './typography.css';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.225rem;
--radius: 0.1rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
@@ -140,6 +140,40 @@
* {
@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 {
@@ -152,7 +186,8 @@
html {
@apply font-sans;
}
button:not(:disabled), [role="button"]:not(:disabled) {
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
}
@@ -165,3 +200,18 @@
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
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

@@ -14,6 +14,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Sidebar,
SidebarContent,
@@ -124,6 +132,7 @@ function SidebarNavItem({
item: MenuItem;
pathname: string;
}) {
const { isMobile, state } = useSidebar();
const isActive = isRouteActive(pathname, item.path);
const isChildActive =
item.children?.some((child) => isRouteActive(pathname, child.path)) ??
@@ -136,6 +145,52 @@ function SidebarNavItem({
}, [isChildActive]);
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 (
<Collapsible
asChild

View File

@@ -30,9 +30,11 @@ export function TableFooter<TData>({
pageSize?: number;
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 pageIndex = table.getState().pagination.pageIndex;
const pageIndex = paginationState?.pageIndex ?? 0;
const rawPageCount = table.getPageCount();
const pageCount = Math.max(
1,

View File

@@ -16,7 +16,7 @@ import { getPinnedColumnStyles } from './columnStyles';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
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) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
@@ -39,7 +39,7 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
canSort ? header.column.getToggleSortingHandler() : undefined
}
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',
sortDirection && 'bg-muted',
)}

View File

@@ -41,10 +41,11 @@ export type ColumnAlign = 'left' | 'right';
export interface DataTableAction<TData> {
label: string | ((item: TData) => string);
icon: React.ReactNode;
icon: React.ReactNode | ((item: TData) => React.ReactNode);
onClick: (item: TData) => void;
permission?: PermissionInput;
disabled?: (item: TData) => boolean;
hidden?: (item: TData) => boolean;
className?: string;
}
@@ -156,10 +157,16 @@ function DataTableContent<TData, TValue>({
return (
<div className="flex justify-start gap-2">
{actions.map((action) => {
if (action.hidden?.(item)) return null;
const label =
typeof action.label === 'function'
? action.label(item)
: action.label;
const icon =
typeof action.icon === 'function'
? action.icon(item)
: action.icon;
return (
<TableActionButton
@@ -173,7 +180,7 @@ function DataTableContent<TData, TValue>({
action.onClick(item);
}}
>
{action.icon}
{icon}
</TableActionButton>
);
})}
@@ -212,37 +219,41 @@ function DataTableContent<TData, TValue>({
manualSorting: isManualSorting,
pageCount: pagination
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
: -1,
: undefined,
state: {
rowSelection,
columnVisibility,
columnPinning,
columnSizing,
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),
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.onLimitChange(newState.pageSize);
pagination.onPageChange(0);
return;
pagination.onPageChange(newState.pageIndex * newState.pageSize);
}
pagination.onPageChange(newState.pageIndex * newState.pageSize);
}
},
: undefined,
});
return (

View File

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

View File

@@ -41,11 +41,7 @@ export default function AnnotatedDetectionImage({
]
: [];
const aspectRatio =
aspectRatioOverride ??
(videoWidth > 0 && videoHeight > 0
? `${videoWidth} / ${videoHeight}`
: '16 / 9');
const aspectRatio = aspectRatioOverride ?? '16 / 9';
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"
style={{ aspectRatio }}
>
<Image
src={objectUrl}
alt={`${detection.detection.display_name} detection`}
fill
unoptimized
sizes="(min-width: 1280px) 18rem, (min-width: 640px) 50vw, 100vw"
className="object-contain"
/>
{annotatedMedia}
<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
</span>

View File

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

View File

@@ -63,14 +63,16 @@ export const API_ROUTES = {
},
TICKETS: {
BASE: '/biz/api/v1/tickets',
SUMMARY: '/biz/api/v1/tickets/summary',
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
CLASS_DETAIL: (id: string, defectClass: string) =>
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
ASSIGNMENT_DETECTIONS: (id: string) =>
`/biz/api/v1/tickets/${id}/assignment-detections`,
TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
`/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`,
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
`/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,
ReviewTicketExtensionPayload,
SseTokenResponse,
TicketDetail,
TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketAssignmentDetectionsResponse,
TicketAssignmentsResponse,
TicketExtensionRequestsResponse,
TicketExtensionRequestsParams,
TicketOverviewDetail,
TicketDefectClassesResponse,
TicketListParams,
TicketListResponse,
TicketSummaryParams,
TicketSummaryResponse,
TicketTimelineResponse,
} from '@/types';
type TicketAssignmentsApiResponse =
| TicketAssignmentSummary[]
| TicketAssignmentsResponse
| {
assignments: TicketAssignmentSummary[];
total?: number;
};
export const ticketService = {
getTickets: async (
params?: TicketListParams,
@@ -33,6 +47,20 @@ export const ticketService = {
return response.data;
},
getTicketSummary: async (
params?: TicketSummaryParams,
): Promise<TicketSummaryResponse> => {
const response = await axiosClient.get<TicketSummaryResponse>(
API_ROUTES.TICKETS.SUMMARY,
{
params: {
chainage_id: params?.chainage_id,
},
},
);
return response.data;
},
getTicketOverview: async (
ticketId: string,
): Promise<TicketOverviewDetail> => {
@@ -42,21 +70,32 @@ export const ticketService = {
return response.data;
},
getTicketClassDetail: async (
getTicketAssignmentDetections: async (
ticketId: string,
defectClass: string,
): Promise<TicketDetail> => {
const response = await axiosClient.get<TicketDetail>(
API_ROUTES.TICKETS.CLASS_DETAIL(ticketId, defectClass),
params?: TicketAssignmentDetectionsParams,
): Promise<TicketAssignmentDetectionsResponse> => {
const response = await axiosClient.get<TicketAssignmentDetectionsResponse>(
API_ROUTES.TICKETS.ASSIGNMENT_DETECTIONS(ticketId),
{
params: {
assignment_status: params?.assignment_status ?? 'unassigned',
class_name: params?.class_name,
skip: params?.skip ?? 0,
limit: params?.limit ?? 500,
},
paramsSerializer: {
indexes: null,
},
},
);
return response.data;
},
getTicketDefectClasses: async (
getTicketTimeline: async (
ticketId: string,
): Promise<TicketDefectClassesResponse> => {
const response = await axiosClient.get<TicketDefectClassesResponse>(
API_ROUTES.TICKETS.DEFECT_CLASSES(ticketId),
): Promise<TicketTimelineResponse> => {
const response = await axiosClient.get<TicketTimelineResponse>(
API_ROUTES.TICKETS.TIMELINE(ticketId),
);
return response.data;
},
@@ -80,14 +119,36 @@ export const ticketService = {
assignTicket: async (
ticketId: string,
payload: AssignTicketPayload,
): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
API_ROUTES.TICKETS.ASSIGN(ticketId),
): Promise<TicketAssignmentSummary> => {
const response = await axiosClient.post<TicketAssignmentSummary>(
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
payload,
);
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 (
ticketId: string,
assignmentId: number,
@@ -105,7 +166,7 @@ export const ticketService = {
assignmentId: number,
): Promise<TicketExtensionRequestsResponse> => {
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
API_ROUTES.TICKETS.EXTENSION_REQUESTS(ticketId),
API_ROUTES.TICKETS.TICKET_EXTENSION_REQUESTS(ticketId),
{
params: { assignment_id: assignmentId },
},
@@ -113,6 +174,16 @@ export const ticketService = {
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 (
ticketId: string,
assignmentId: number,
@@ -124,11 +195,12 @@ export const ticketService = {
);
return response.data;
},
closeTicket: async (
ticketId: string,
payload: CloseTicketPayload,
): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
): Promise<unknown> => {
const response = await axiosClient.post<unknown>(
API_ROUTES.TICKETS.CLOSE(ticketId),
payload,
);

View File

@@ -73,12 +73,17 @@ export const videoService = {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 1,
assignment_id: params?.assignment_id,
assignment_status: params?.assignment_status,
class_name: params?.class_name,
proof_status: params?.proof_status,
min_confidence: params?.min_confidence,
sort: params?.sort ?? 'timestamp_asc',
search: params?.search,
},
paramsSerializer: {
indexes: null,
},
},
);
return response.data;

View File

@@ -75,9 +75,13 @@ export type DetectionResultLog = DetectionResultItem & {
};
};
export type AssignmentDetectionStatus = 'assigned' | 'unassigned';
export type VideoDetectionsParams = {
skip?: number;
limit?: number;
assignment_id?: number;
assignment_status?: AssignmentDetectionStatus;
class_name?: string | string[];
proof_status?: DetectionProofStatus;
min_confidence?: number;
@@ -103,6 +107,35 @@ export type VideoDetectionsResponse = {
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 =
| 'not_a_defect'
| 'wrong_class'

View File

@@ -22,11 +22,21 @@ export interface AssignableTicketUsersResponse {
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 {
assigned_to_user_id: number;
defect_class: string;
assigned_to_email: string;
due_at: string;
note?: string;
criteria?: TicketAssignmentCriteria;
}
export interface RequestTicketExtensionPayload {
@@ -36,7 +46,7 @@ export interface RequestTicketExtensionPayload {
export interface ReviewTicketExtensionPayload {
action: 'approve' | 'reject';
note: string;
note?: string;
}
export interface TicketExtensionRequestActor {
@@ -48,19 +58,33 @@ export interface TicketExtensionRequestActor {
};
}
export type TicketExtensionRequestStatus =
| 'pending'
| 'approved'
| 'rejected'
| 'cancelled';
export interface TicketExtensionRequest {
id: number;
ticket_id: string;
ticket_name: string;
assignment_id: number;
defect_class: string;
defect_class?: string | null;
current_due_at: string;
requested_due_at: string;
reason: string;
status: 'pending' | 'approved' | 'rejected' | 'cancelled';
status: TicketExtensionRequestStatus;
requested_by: TicketExtensionRequestActor | null;
requested_at: string;
resolved_by: TicketExtensionRequestActor | 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 {
@@ -68,6 +92,11 @@ export interface TicketExtensionRequestsResponse {
total: number;
}
export interface TicketExtensionRequestsParams extends PaginationParams {
ticket_name?: string;
status?: TicketExtensionRequestStatus;
}
export interface CloseTicketPayload {
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 { TicketStatus } from './status';
import type { TicketAssignmentCriteria } from './actions';
export interface TicketActor {
user_id: number | null;
@@ -8,38 +7,6 @@ export interface TicketActor {
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 {
id: string;
name: string | null;
@@ -48,40 +15,6 @@ export interface TicketVideo {
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 {
created_at: string;
updated_at: string;
@@ -114,9 +47,34 @@ export interface TicketDetectionClassCount {
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 {
id: string;
ticket_name?: string | null;
has_pending_review: boolean;
pending_review_count: number;
video_id?: string | null;
video?: TicketVideo | null;
chainage_id?: string | null;
@@ -134,27 +92,6 @@ export interface TicketOverviewDetail {
available_defect_classes: string[];
detections_by_class: TicketDetectionClassCount[];
};
assignments?: TicketAssignmentSummary[];
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 { TicketAttentionSummary } from './list';
export interface SseTokenResponse {
sse_token: string;
@@ -15,14 +15,16 @@ export type TicketTableStatusEvent = {
chainage_name?: string | null;
default_defect_class?: string | null;
detection_count?: number;
is_closed?: boolean;
created_by_name?: string | null;
created_by_email?: string | null;
created_at?: string;
updated_at?: string;
attention_summary?: TicketAttentionSummary;
message?: string;
};
export type TicketDetailStatusEvent = Partial<TicketDetail> & {
export type TicketDetailStatusEvent = {
type: 'ticket_status' | 'heartbeat';
kind?: 'snapshot' | 'transitioned';
id?: string;

View File

@@ -2,5 +2,6 @@ export * from './status';
export * from './list';
export * from './detail';
export * from './actions';
export * from './defect-class';
export * from './events';
export * from './timeline';
export * from './summary';

View File

@@ -4,6 +4,13 @@ export interface TicketListParams extends PaginationParams {
chainage_id?: string;
}
export interface TicketAttentionSummary {
has_pending_extension_request: boolean;
pending_extension_request_count: number;
has_pending_repair_review: boolean;
pending_repair_review_count: number;
}
export interface TicketListItem {
id: string;
ticket_name: string;
@@ -11,10 +18,12 @@ export interface TicketListItem {
chainage_name: string | null;
default_defect_class: string | null;
detection_count: number;
is_closed: boolean;
created_by_name: string | null;
created_by_email: string | null;
created_at: string;
updated_at: string;
attention_summary: TicketAttentionSummary;
}
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',
UPLOAD: '/upload',
TICKET: '/ticket',
EXTENSION_REQUESTS: '/extension-requests',
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
TICKET_CLASS_REVIEW: (ticketId: string, defectClass: string) =>
`/ticket/${ticketId}/${defectClass}/review`,
TICKET_ASSIGN: (ticketId: string) => `/ticket/${ticketId}/assign`,
TICKET_REVIEW: (ticketId: string) => `/ticket/${ticketId}/review`,
} as const;