6 Commits

48 changed files with 1405 additions and 730 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 / Lot',
size: 170,
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.ticket_name}</p>
<p className="text-xs text-muted-foreground">
Lot #{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}, Lot #{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}, Lot #{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,123 @@
'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() });
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 lots."
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

@@ -146,7 +146,9 @@ export function TicketAssignmentsCard({
ticketId, ticketId,
initialAssignments, initialAssignments,
); );
const assignments = assignmentsQuery.data?.items ?? []; const assignments = (assignmentsQuery.data?.items ?? []).filter(
(assignment) => assignment.item_count > 0,
);
const selectedAssignment = const selectedAssignment =
assignments.find((assignment) => assignment.id === selectedAssignmentId) ?? assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
assignments[0]; assignments[0];
@@ -178,7 +180,7 @@ export function TicketAssignmentsCard({
</CardDescription> </CardDescription>
</div> </div>
{assignmentsQuery.data ? ( {assignmentsQuery.data ? (
<Badge variant="secondary">{assignmentsQuery.data.total}</Badge> <Badge variant="secondary">{assignments.length}</Badge>
) : null} ) : null}
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -1,70 +0,0 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import type { TicketOverviewDetail } from '@/types';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps {
ticketId: string;
ticket?: TicketOverviewDetail;
assignmentId?: number;
}
export function TicketDefectClassTabs({
ticketId,
ticket,
assignmentId,
}: TicketDefectClassTabsProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const defectClassParam = searchParams.get('defect_class');
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
useEffect(() => {
const defectClasses = defectClassesQuery.data?.defect_classes ?? [];
if (!defectClasses.length) return;
const hasUrlClass = defectClasses.some(
(item) => item.class_name === defectClassParam,
);
if (hasUrlClass) return;
const firstClass = defectClasses[0].class_name;
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', firstClass);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
}, [
defectClassParam,
defectClassesQuery.data?.defect_classes,
pathname,
router,
searchParams,
]);
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
if (!previewVideoId) return null;
return (
<Card className="w-full">
<CardContent className="p-5">
<div className="min-w-0">
<TicketClassDetectionPreview
ticketId={ticketId}
videoId={previewVideoId}
assignmentId={assignmentId}
totalCount={ticket?.ai_result.detection_count}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -6,17 +6,15 @@ import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketDetail, TicketOverviewDetail } from '@/types'; import type { TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction'; import { AssignTicketAction } from './actions/AssignTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction'; import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({ export function TicketDetailHeader({
ticket, ticket,
classDetail,
}: { }: {
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
classDetail?: TicketDetail;
}) { }) {
const router = useRouter(); const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id; const ticketLabel = ticket.ticket_name || ticket.id;
@@ -29,11 +27,9 @@ export function TicketDetailHeader({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{classDetail?.assignment_status === 'under_review' ? ( <PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}> <OpenRepairReviewAction ticketId={ticket.id} />
<OpenRepairReviewAction ticket={classDetail} /> </PermissionGuard>
</PermissionGuard>
) : null}
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}> <PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} /> <AssignTicketAction ticketId={ticket.id} />
</PermissionGuard> </PermissionGuard>

View File

@@ -17,11 +17,10 @@ import {
} from '../../hooks/useTicketQueries'; } from '../../hooks/useTicketQueries';
import { DetectionDiscardDialog } from './DetectionDiscardDialog'; import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketClassDetectionPreviewProps { interface TicketDetectionPreviewProps {
ticketId: string; ticketId: string;
videoId?: string | null; videoId?: string | null;
assignmentId?: number; assignmentId: number;
totalCount?: number;
} }
function DetectionMetadataBar({ function DetectionMetadataBar({
@@ -53,7 +52,7 @@ function DetectionMetadataBar({
); );
} }
function TicketClassDetectionPreviewSkeleton() { function TicketDetectionPreviewSkeleton() {
return ( return (
<> <>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
@@ -92,12 +91,11 @@ function TicketClassDetectionPreviewSkeleton() {
); );
} }
export function TicketClassDetectionPreview({ export function TicketDetectionPreview({
ticketId, ticketId,
videoId, videoId,
assignmentId, assignmentId,
totalCount, }: TicketDetectionPreviewProps) {
}: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false); const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
@@ -122,8 +120,7 @@ export function TicketClassDetectionPreview({
const detectionResult = detectionsQuery.data; const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0]; const activeDetection = detectionResult?.items[0];
const confirmedDetectionCount = detectionResult?.total; const confirmedDetectionCount = detectionResult?.total;
const detectionCount = const detectionCount = detectionResult?.total ?? 0;
detectionResult?.total ?? (assignmentId ? 0 : (totalCount ?? 0));
const activeVisual = getDefectVisual( const activeVisual = getDefectVisual(
activeDetection?.detection.class_name ?? '', activeDetection?.detection.class_name ?? '',
); );
@@ -133,7 +130,6 @@ export function TicketClassDetectionPreview({
const discardMutation = useDiscardDetectionMutation( const discardMutation = useDiscardDetectionMutation(
ticketId, ticketId,
videoId ?? undefined, videoId ?? undefined,
activeDetection?.detection.class_name,
); );
useEffect(() => { useEffect(() => {
@@ -220,7 +216,7 @@ export function TicketClassDetectionPreview({
</div> </div>
{detectionsQuery.isLoading && !detectionsQuery.data ? ( {detectionsQuery.isLoading && !detectionsQuery.data ? (
<TicketClassDetectionPreviewSkeleton /> <TicketDetectionPreviewSkeleton />
) : detectionsQuery.isError ? ( ) : detectionsQuery.isError ? (
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center"> <div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">

View File

@@ -0,0 +1,33 @@
'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
ticketId={ticketId}
videoId={videoId}
assignmentId={assignmentId}
/>
</div>
</CardContent>
</Card>
);
}

View File

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

View File

@@ -2,21 +2,19 @@
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketAssignmentSummary, TicketDetail } from '@/types'; import type { TicketAssignmentSummary } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction'; import { NoTicketAction } from './actions/NoTicketAction';
import { RequestExtensionAction } from './actions/RequestExtensionAction'; import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction'; import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
interface TicketStatusActionsProps { interface TicketStatusActionsProps {
ticket: TicketDetail; ticketId: string;
assignment?: TicketAssignmentSummary; assignment?: TicketAssignmentSummary;
} }
function getTicketAction( function getTicketAction(
ticket: TicketDetail, ticketId: string,
assignment?: TicketAssignmentSummary, assignment?: TicketAssignmentSummary,
) { ) {
if (assignment && !assignment.is_complete) { if (assignment && !assignment.is_complete) {
@@ -29,60 +27,29 @@ function getTicketAction(
fallback={<NoTicketAction />} fallback={<NoTicketAction />}
> >
<RequestExtensionAction <RequestExtensionAction
ticket={ticket} ticketId={ticketId}
assignmentId={assignment.id} assignmentId={assignment.id}
dueAt={assignment.due_at} dueAt={assignment.due_at}
/> />
</PermissionGuard> </PermissionGuard>
} }
> >
<> <ReviewExtensionRequestAction
{ticket.assignment_status === 'assigned' ? ( ticketId={ticketId}
<AssignedContractorAction ticket={ticket} /> assignmentId={assignment.id}
) : null} />
<ReviewExtensionRequestAction
ticket={ticket}
assignmentId={assignment.id}
/>
</>
</PermissionGuard> </PermissionGuard>
); );
} }
if (ticket.assignment_status === 'unassigned') { return null;
return null;
}
if (ticket.assignment_status === 'assigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />}
>
<AssignedContractorAction ticket={ticket} />
</PermissionGuard>
);
}
if (ticket.assignment_status === 'under_review') {
return null;
}
if (
ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected'
) {
return <ClosedTicketAction ticket={ticket} />;
}
return <NoTicketAction />;
} }
export function TicketStatusActions({ export function TicketStatusActions({
ticket, ticketId,
assignment, assignment,
}: TicketStatusActionsProps) { }: TicketStatusActionsProps) {
const action = getTicketAction(ticket, assignment); const action = getTicketAction(ticketId, assignment);
return action ? <div className="space-y-3">{action}</div> : null; return action ? <div className="space-y-3">{action}</div> : null;
} }

View File

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

View File

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

@@ -4,18 +4,15 @@ import { useRouter } from 'next/navigation';
import { ClipboardCheck } from 'lucide-react'; import { ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) { export function OpenRepairReviewAction({ ticketId }: { ticketId: string }) {
const router = useRouter(); const router = useRouter();
return ( return (
<Button <Button
type="button" type="button"
onClick={() => onClick={() => router.push(ROUTES.TICKET_REVIEW(ticketId))}
router.push(ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class))
}
> >
<ClipboardCheck /> <ClipboardCheck />
Review Repairs Review Repairs

View File

@@ -1,23 +1,21 @@
import { Clock3 } from 'lucide-react'; import { Clock3 } from 'lucide-react';
import type { TicketDetail } from '@/types';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel'; import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
export function RequestExtensionAction({ export function RequestExtensionAction({
ticket, ticketId,
assignmentId, assignmentId,
dueAt, dueAt,
}: { }: {
ticket: TicketDetail; ticketId: string;
assignmentId: number; assignmentId: number;
dueAt: string | null; dueAt: string | null;
}) { }) {
return ( return (
<TicketActionCard icon={Clock3} title="Request Extension"> <TicketActionCard icon={Clock3} title="Request Extension">
<TicketExtensionRequestPanel <TicketExtensionRequestPanel
ticket={ticket} ticketId={ticketId}
assignmentId={assignmentId} assignmentId={assignmentId}
dueAt={dueAt} dueAt={dueAt}
/> />

View File

@@ -8,20 +8,19 @@ import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import type { TicketDetail } from '@/types';
import { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries'; import { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries';
const MAX_REASON_LENGTH = 300; const MAX_REASON_LENGTH = 300;
interface RequestExtensionFormProps { interface RequestExtensionFormProps {
ticket: TicketDetail; ticketId: string;
assignmentId: number; assignmentId: number;
currentDueAt: string | null; currentDueAt: string | null;
} }
export function RequestExtensionForm({ export function RequestExtensionForm({
ticket, ticketId,
assignmentId, assignmentId,
currentDueAt, currentDueAt,
}: RequestExtensionFormProps) { }: RequestExtensionFormProps) {
@@ -42,9 +41,8 @@ export function RequestExtensionForm({
const [requestedDueDate, setRequestedDueDate] = useState<Date>(); const [requestedDueDate, setRequestedDueDate] = useState<Date>();
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const requestExtensionMutation = useRequestTicketExtensionMutation( const requestExtensionMutation = useRequestTicketExtensionMutation(
ticket.id, ticketId,
assignmentId, assignmentId,
ticket.defect_class,
); );
const isPending = requestExtensionMutation.isPending; const isPending = requestExtensionMutation.isPending;
const isRequestedDateValid = Boolean( const isRequestedDateValid = Boolean(

View File

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

View File

@@ -14,7 +14,6 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { import type {
TicketDetail,
TicketExtensionRequest, TicketExtensionRequest,
TicketExtensionRequestsResponse, TicketExtensionRequestsResponse,
} from '@/types'; } from '@/types';
@@ -186,16 +185,16 @@ function ExtensionRequestDetails({
} }
export function TicketExtensionRequestPanel({ export function TicketExtensionRequestPanel({
ticket, ticketId,
assignmentId, assignmentId,
dueAt, dueAt,
}: { }: {
ticket: TicketDetail; ticketId: string;
assignmentId: number; assignmentId: number;
dueAt: string | null; dueAt: string | null;
}) { }) {
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id, ticketId,
assignmentId, assignmentId,
); );
@@ -234,7 +233,7 @@ export function TicketExtensionRequestPanel({
if (!latestRequest) { if (!latestRequest) {
return ( return (
<RequestExtensionForm <RequestExtensionForm
ticket={ticket} ticketId={ticketId}
assignmentId={assignmentId} assignmentId={assignmentId}
currentDueAt={dueAt} currentDueAt={dueAt}
/> />

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -11,28 +11,20 @@ import {
TicketOverviewCardSkeleton, TicketOverviewCardSkeleton,
TicketOverviewHeaderSkeleton, TicketOverviewHeaderSkeleton,
} from './components/TicketOverviewSkeleton'; } from './components/TicketOverviewSkeleton';
import {
TicketClassActionsSkeleton,
TicketClassContentSkeleton,
} from './components/TicketClassDetailSkeleton';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard'; import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs'; import { TicketDetectionPreviewCard } from './components/TicketDetectionPreviewCard';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { import {
useTicketAssignmentsQuery, useTicketAssignmentsQuery,
useTicketClassDetailQuery,
useTicketOverviewQuery, useTicketOverviewQuery,
} from '../hooks/useTicketQueries'; } from '../hooks/useTicketQueries';
export default function TicketDetailPage() { export default function TicketDetailPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string }; const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined;
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>(); const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
@@ -41,18 +33,15 @@ export default function TicketDetailPage() {
ticketId, ticketId,
overview?.assignments, overview?.assignments,
); );
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass); const assignments = (assignmentsQuery.data?.items ?? []).filter(
const classDetail = classDetailQuery.data; (assignment) => assignment.item_count > 0,
const assignments = assignmentsQuery.data?.items ?? [];
const activeAssignmentId = selectedAssignmentId ?? assignments[0]?.id;
const activeAssignment = assignments.find(
(assignment) => assignment.id === activeAssignmentId,
);
const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading,
); );
const activeAssignment =
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
assignments[0];
const activeAssignmentId = activeAssignment?.id;
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId)); useTicketDetailEvents(ticketId, Boolean(ticketId));
if (overviewQuery.isError) { if (overviewQuery.isError) {
return ( return (
@@ -74,7 +63,7 @@ export default function TicketDetailPage() {
<main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden"> <main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<div className="xl:shrink-0"> <div className="xl:shrink-0">
{overview ? ( {overview ? (
<TicketDetailHeader ticket={overview} classDetail={classDetail} /> <TicketDetailHeader ticket={overview} />
) : ( ) : (
<TicketOverviewHeaderSkeleton /> <TicketOverviewHeaderSkeleton />
)} )}
@@ -95,22 +84,20 @@ export default function TicketDetailPage() {
onAssignmentChange={setSelectedAssignmentId} onAssignmentChange={setSelectedAssignmentId}
/> />
) : null} ) : null}
<TicketDefectClassTabs {activeAssignment ? (
ticketId={ticketId} <TicketDetectionPreviewCard
ticket={overview} ticketId={ticketId}
assignmentId={activeAssignmentId} videoId={overview?.video_id ?? overview?.video?.id}
/> assignmentId={activeAssignment.id}
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null} />
{!isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} />
) : null} ) : null}
<TicketHistoryCard ticketId={ticketId} />
</div> </div>
<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"> <div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null} {activeAssignment ? (
{!isClassDetailLoading && classDetail ? (
<TicketStatusActions <TicketStatusActions
ticket={classDetail} ticketId={ticketId}
assignment={activeAssignment} assignment={activeAssignment}
/> />
) : null} ) : null}

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,8 +17,6 @@ import type {
DiscardDetectionPayload, DiscardDetectionPayload,
RequestTicketExtensionPayload, RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload, ReviewDetectionRepairProofPayload,
ReviewTicketExtensionPayload,
TicketDetail,
TicketAssignmentSummary, TicketAssignmentSummary,
TicketListParams, TicketListParams,
DetectionProofStatus, DetectionProofStatus,
@@ -26,6 +24,7 @@ import type {
} from '@/types'; } from '@/types';
import { ticketKeys } from '../queries/ticketKeys'; import { ticketKeys } from '../queries/ticketKeys';
import { extensionRequestKeys } from '../../extension-requests/queries/extensionRequestKeys';
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000; const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
@@ -70,18 +69,11 @@ export function useTicketOverviewQuery(ticketId: string | undefined) {
}); });
} }
export function useTicketClassDetailQuery( export function useTicketTimelineQuery(ticketId: string | undefined) {
ticketId: string | undefined,
defectClass: string | undefined,
) {
return useQuery({ return useQuery({
queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''), queryKey: ticketKeys.timeline(ticketId ?? ''),
queryFn: () => queryFn: () => ticketService.getTicketTimeline(ticketId as string),
ticketService.getTicketClassDetail( enabled: Boolean(ticketId),
ticketId as string,
defectClass as string,
),
enabled: Boolean(ticketId && defectClass),
}); });
} }
@@ -189,22 +181,16 @@ export function useTicketClassDetectionsQuery(
return useTicketDetectionsQuery(videoId, params); return useTicketDetectionsQuery(videoId, params);
} }
export function useTicketClassReviewDetectionsQuery( export function useTicketReviewDetectionsQuery(
videoId: string | undefined, videoId: string | undefined,
defectClass: string | undefined,
proofStatus?: DetectionProofStatus, proofStatus?: DetectionProofStatus,
) { ) {
const pageSize = 20; const pageSize = 20;
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ticketKeys.classReviewDetections( queryKey: ticketKeys.reviewDetections(videoId ?? '', proofStatus),
videoId ?? '',
defectClass ?? '',
proofStatus,
),
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
videoService.getVideoDetections(videoId as string, { videoService.getVideoDetections(videoId as string, {
class_name: defectClass,
proof_status: proofStatus, proof_status: proofStatus,
sort: 'timestamp_asc', sort: 'timestamp_asc',
skip: pageParam, skip: pageParam,
@@ -215,14 +201,13 @@ export function useTicketClassReviewDetectionsQuery(
const nextSkip = lastPage.skip + lastPage.items.length; const nextSkip = lastPage.skip + lastPage.items.length;
return nextSkip < lastPage.total ? nextSkip : undefined; return nextSkip < lastPage.total ? nextSkip : undefined;
}, },
enabled: Boolean(videoId && defectClass), enabled: Boolean(videoId),
}); });
} }
export function useDiscardDetectionMutation( export function useDiscardDetectionMutation(
ticketId: string, ticketId: string,
videoId: string | undefined, videoId: string | undefined,
defectClass?: string,
) { ) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -246,13 +231,6 @@ export function useDiscardDetectionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
...(defectClass
? [
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
}),
]
: []),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId), queryKey: ticketKeys.defectClasses(ticketId),
}), }),
@@ -269,11 +247,9 @@ export function useDiscardDetectionMutation(
export function useReviewDetectionRepairProofMutation({ export function useReviewDetectionRepairProofMutation({
ticketId, ticketId,
videoId, videoId,
defectClass,
}: { }: {
ticketId: string; ticketId: string;
videoId: string | undefined; videoId: string | undefined;
defectClass: string;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -298,15 +274,15 @@ export function useReviewDetectionRepairProofMutation({
queryKey: ticketKeys.classDetectionLists(videoId), queryKey: ticketKeys.classDetectionLists(videoId),
}) })
: Promise.resolve(), : Promise.resolve(),
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
}),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId), queryKey: ticketKeys.defectClasses(ticketId),
}), }),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
]); ]);
}, },
@@ -337,19 +313,6 @@ 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, videoId?: string) { export function useAssignTicketMutation(ticketId: string, videoId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -362,6 +325,9 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
...(videoId ...(videoId
? [ ? [
@@ -382,7 +348,6 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
export function useRequestTicketExtensionMutation( export function useRequestTicketExtensionMutation(
ticketId: string, ticketId: string,
assignmentId: number | undefined, assignmentId: number | undefined,
defectClass: string,
) { ) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -403,58 +368,36 @@ export function useRequestTicketExtensionMutation(
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId), queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
}); });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass), queryKey: extensionRequestKeys.lists(),
}); });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId), queryKey: ticketKeys.assignments(ticketId),
}); });
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to request an extension'), onError: () => toast.error('Failed to request an extension'),
}); });
} }
export function useReviewTicketExtensionMutation(
ticketId: string,
assignmentId: number,
defectClass: string,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ReviewTicketExtensionPayload) =>
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
onSuccess: (_response, payload) => {
toast.success(
payload.action === 'approve'
? 'Extension request approved'
: 'Extension request rejected',
);
queryClient.invalidateQueries({
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to review extension request'),
});
}
export function useCloseTicketMutation(ticketId: string) { export function useCloseTicketMutation(ticketId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (payload: CloseTicketPayload) => mutationFn: (payload: CloseTicketPayload) =>
ticketService.closeTicket(ticketId, payload), ticketService.closeTicket(ticketId, payload),
onSuccess: (ticket, payload) => { onSuccess: () => {
toast.success('Ticket closed'); toast.success('Ticket closed');
queryClient.setQueryData<TicketDetail>( queryClient.invalidateQueries({
ticketKeys.classDetail(ticketId, payload.defect_class), queryKey: ticketKeys.overview(ticketId),
(current) => mergeTicketDetailResponse(current, ticket), });
); queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to close ticket'), onError: () => toast.error('Failed to close ticket'),

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Ticket } from 'lucide-react'; import { Ticket } from 'lucide-react';
import type { TicketListItem } from '@/types'; import type { TicketListItem } from '@/types';
import { ROUTES } from '@/utils/routes';
import { TicketFilters } from './components/TicketFilters'; import { TicketFilters } from './components/TicketFilters';
import { TicketTable } from './components/TicketTable'; import { TicketTable } from './components/TicketTable';
@@ -37,11 +38,7 @@ export default function TicketPage() {
const handleView = useCallback( const handleView = useCallback(
(ticket: TicketListItem) => { (ticket: TicketListItem) => {
const defectClassQuery = ticket.default_defect_class router.push(ROUTES.TICKET_DETAIL(ticket.id));
? `?defect_class=${encodeURIComponent(ticket.default_defect_class)}`
: '';
router.push(`/ticket/${ticket.id}${defectClassQuery}`);
}, },
[router], [router],
); );

View File

@@ -11,8 +11,8 @@ export const ticketKeys = {
details: () => [...ticketKeys.all, 'detail'] as const, details: () => [...ticketKeys.all, 'detail'] as const,
overview: (ticketId: string) => overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'overview'] as const, [...ticketKeys.details(), ticketId, 'overview'] as const,
classDetail: (ticketId: string, defectClass: string) => timeline: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const, [...ticketKeys.details(), ticketId, 'timeline'] as const,
classDetectionLists: (videoId: string) => classDetectionLists: (videoId: string) =>
[...ticketKeys.details(), 'video', videoId, 'detections'] as const, [...ticketKeys.details(), 'video', videoId, 'detections'] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) => classDetections: (videoId: string, params: VideoDetectionsParams) =>
@@ -26,15 +26,10 @@ export const ticketKeys = {
] as const, ] as const,
detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) => detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) =>
[...ticketKeys.detectionCoordinateLists(videoId), params] as const, [...ticketKeys.detectionCoordinateLists(videoId), params] as const,
classReviewDetections: ( reviewDetections: (videoId: string, proofStatus?: string) =>
videoId: string,
defectClass: string,
proofStatus?: string,
) =>
[ [
...ticketKeys.classDetectionLists(videoId), ...ticketKeys.classDetectionLists(videoId),
'class-review', 'review',
defectClass,
proofStatus ?? 'all', proofStatus ?? 'all',
] as const, ] as const,
defectClasses: (ticketId: string) => defectClasses: (ticketId: string) =>

View File

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

View File

@@ -45,6 +45,7 @@ export interface DataTableAction<TData> {
onClick: (item: TData) => void; onClick: (item: TData) => void;
permission?: PermissionInput; permission?: PermissionInput;
disabled?: (item: TData) => boolean; disabled?: (item: TData) => boolean;
hidden?: (item: TData) => boolean;
className?: string; className?: string;
} }
@@ -156,6 +157,8 @@ function DataTableContent<TData, TValue>({
return ( return (
<div className="flex justify-start gap-2"> <div className="flex justify-start gap-2">
{actions.map((action) => { {actions.map((action) => {
if (action.hidden?.(item)) return null;
const label = const label =
typeof action.label === 'function' typeof action.label === 'function'
? action.label(item) ? action.label(item)
@@ -212,37 +215,41 @@ function DataTableContent<TData, TValue>({
manualSorting: isManualSorting, manualSorting: isManualSorting,
pageCount: pagination pageCount: pagination
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit) ? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
: -1, : undefined,
state: { state: {
rowSelection, rowSelection,
columnVisibility, columnVisibility,
columnPinning, columnPinning,
columnSizing, columnSizing,
sorting, sorting,
pagination: pagination ...(pagination
? { ? {
pagination: {
pageIndex: Math.floor(pagination.skip / pagination.limit),
pageSize: pagination.limit,
},
}
: {}),
},
onPaginationChange: pagination
? (updater) => {
const currentState = {
pageIndex: Math.floor(pagination.skip / pagination.limit), pageIndex: Math.floor(pagination.skip / pagination.limit),
pageSize: pagination.limit, pageSize: pagination.limit,
};
const newState =
typeof updater === 'function' ? updater(currentState) : updater;
const pageSizeChanged = newState.pageSize !== pagination.limit;
if (pageSizeChanged) {
pagination.onLimitChange(newState.pageSize);
pagination.onPageChange(0);
return;
} }
: undefined,
},
onPaginationChange: (updater) => {
if (typeof updater === 'function' && pagination) {
const newState = updater({
pageIndex: Math.floor(pagination.skip / pagination.limit),
pageSize: pagination.limit,
});
const pageSizeChanged = newState.pageSize !== pagination.limit;
if (pageSizeChanged) { pagination.onPageChange(newState.pageIndex * newState.pageSize);
pagination.onLimitChange(newState.pageSize);
pagination.onPageChange(0);
return;
} }
: undefined,
pagination.onPageChange(newState.pageIndex * newState.pageSize);
}
},
}); });
return ( return (

View File

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

View File

@@ -65,13 +65,13 @@ export const API_ROUTES = {
TICKETS: { TICKETS: {
BASE: '/biz/api/v1/tickets', BASE: '/biz/api/v1/tickets',
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`, DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
CLASS_DETAIL: (id: string, defectClass: string) => TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`, DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`, ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) => REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`, `/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
EXTENSION_REQUESTS: (ticketId: string) => EXTENSION_REQUESTS: '/biz/api/v1/tickets/extension-requests',
TICKET_EXTENSION_REQUESTS: (ticketId: string) =>
`/biz/api/v1/tickets/${ticketId}/extension-requests`, `/biz/api/v1/tickets/${ticketId}/extension-requests`,
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) => REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`, `/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`,

View File

@@ -8,14 +8,15 @@ import type {
RequestTicketExtensionPayload, RequestTicketExtensionPayload,
ReviewTicketExtensionPayload, ReviewTicketExtensionPayload,
SseTokenResponse, SseTokenResponse,
TicketDetail,
TicketAssignmentSummary, TicketAssignmentSummary,
TicketAssignmentsResponse, TicketAssignmentsResponse,
TicketExtensionRequestsResponse, TicketExtensionRequestsResponse,
TicketExtensionRequestsParams,
TicketOverviewDetail, TicketOverviewDetail,
TicketDefectClassesResponse, TicketDefectClassesResponse,
TicketListParams, TicketListParams,
TicketListResponse, TicketListResponse,
TicketTimelineResponse,
} from '@/types'; } from '@/types';
type TicketAssignmentsApiResponse = type TicketAssignmentsApiResponse =
@@ -52,12 +53,11 @@ export const ticketService = {
return response.data; return response.data;
}, },
getTicketClassDetail: async ( getTicketTimeline: async (
ticketId: string, ticketId: string,
defectClass: string, ): Promise<TicketTimelineResponse> => {
): Promise<TicketDetail> => { const response = await axiosClient.get<TicketTimelineResponse>(
const response = await axiosClient.get<TicketDetail>( API_ROUTES.TICKETS.TIMELINE(ticketId),
API_ROUTES.TICKETS.CLASS_DETAIL(ticketId, defectClass),
); );
return response.data; return response.data;
}, },
@@ -137,7 +137,7 @@ export const ticketService = {
assignmentId: number, assignmentId: number,
): Promise<TicketExtensionRequestsResponse> => { ): Promise<TicketExtensionRequestsResponse> => {
const response = await axiosClient.get<TicketExtensionRequestsResponse>( const response = await axiosClient.get<TicketExtensionRequestsResponse>(
API_ROUTES.TICKETS.EXTENSION_REQUESTS(ticketId), API_ROUTES.TICKETS.TICKET_EXTENSION_REQUESTS(ticketId),
{ {
params: { assignment_id: assignmentId }, params: { assignment_id: assignmentId },
}, },
@@ -145,6 +145,16 @@ export const ticketService = {
return response.data; return response.data;
}, },
getTicketExtensionRequests: async (
params: TicketExtensionRequestsParams,
): Promise<TicketExtensionRequestsResponse> => {
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
API_ROUTES.TICKETS.EXTENSION_REQUESTS,
{ params },
);
return response.data;
},
reviewTicketExtension: async ( reviewTicketExtension: async (
ticketId: string, ticketId: string,
assignmentId: number, assignmentId: number,
@@ -156,11 +166,12 @@ export const ticketService = {
); );
return response.data; return response.data;
}, },
closeTicket: async ( closeTicket: async (
ticketId: string, ticketId: string,
payload: CloseTicketPayload, payload: CloseTicketPayload,
): Promise<TicketDetail> => { ): Promise<unknown> => {
const response = await axiosClient.post<TicketDetail>( const response = await axiosClient.post<unknown>(
API_ROUTES.TICKETS.CLOSE(ticketId), API_ROUTES.TICKETS.CLOSE(ticketId),
payload, payload,
); );

View File

@@ -46,7 +46,7 @@ export interface RequestTicketExtensionPayload {
export interface ReviewTicketExtensionPayload { export interface ReviewTicketExtensionPayload {
action: 'approve' | 'reject'; action: 'approve' | 'reject';
note: string; note?: string;
} }
export interface TicketExtensionRequestActor { export interface TicketExtensionRequestActor {
@@ -58,19 +58,33 @@ export interface TicketExtensionRequestActor {
}; };
} }
export type TicketExtensionRequestStatus =
| 'pending'
| 'approved'
| 'rejected'
| 'cancelled';
export interface TicketExtensionRequest { export interface TicketExtensionRequest {
id: number; id: number;
ticket_id: string; ticket_id: string;
ticket_name: string;
assignment_id: number; assignment_id: number;
defect_class: string; defect_class?: string | null;
current_due_at: string; current_due_at: string;
requested_due_at: string; requested_due_at: string;
reason: string; reason: string;
status: 'pending' | 'approved' | 'rejected' | 'cancelled'; status: TicketExtensionRequestStatus;
requested_by: TicketExtensionRequestActor | null; requested_by: TicketExtensionRequestActor | null;
requested_at: string; requested_at: string;
resolved_by: TicketExtensionRequestActor | null; resolved_by: TicketExtensionRequestActor | null;
resolved_at: string | null; resolved_at: string | null;
decision_note: string | null;
history_meta: {
previous_request_count: number;
previous_approved_count: number;
previous_rejected_count: number;
previous_cancelled_count: number;
};
} }
export interface TicketExtensionRequestsResponse { export interface TicketExtensionRequestsResponse {
@@ -78,6 +92,11 @@ export interface TicketExtensionRequestsResponse {
total: number; total: number;
} }
export interface TicketExtensionRequestsParams extends PaginationParams {
ticket_name?: string;
status?: TicketExtensionRequestStatus;
}
export interface CloseTicketPayload { export interface CloseTicketPayload {
defect_class: string; defect_class: string;
} }

View File

@@ -1,5 +1,3 @@
import type { TicketDefectClassTicketStatus } from './defect-class';
import type { TicketStatus } from './status';
import type { TicketAssignmentCriteria } from './actions'; import type { TicketAssignmentCriteria } from './actions';
export interface TicketActor { export interface TicketActor {
@@ -9,38 +7,6 @@ export interface TicketActor {
avatar_url?: string | null; avatar_url?: string | null;
} }
export type TicketHistorySource = 'system' | 'user';
export interface TicketHistoryNote {
kind: string;
author: TicketActor | null;
text: string;
}
export interface TicketHistoryItem {
id: number;
event_type: string;
title: string;
source: TicketHistorySource;
assignment_id: number | null;
defect_class: string | null;
from_class_status: TicketDefectClassTicketStatus | null;
to_class_status: TicketDefectClassTicketStatus | null;
actor: TicketActor | null;
target_user: TicketActor | null;
note: TicketHistoryNote | null;
created_at: string;
}
export interface TicketStatusMetadata {
reason: string | null;
label: string | null;
message: string | null;
detection_count?: number | null;
requires_action?: boolean;
review_comment?: string | null;
}
export interface TicketVideo { export interface TicketVideo {
id: string; id: string;
name: string | null; name: string | null;
@@ -49,40 +15,6 @@ export interface TicketVideo {
thumbnail: string | null; thumbnail: string | null;
} }
export interface TicketWorker extends TicketActor {
assignment_id: number;
assigned_at: string | null;
due_at: string | null;
assigned_by_user_id: number | null;
assigned_by_name: string | null;
role: {
id: number;
name: string;
} | null;
}
export interface TicketRepair {
notes: string | null;
proof_video_url: string | null;
proof_image_urls: string[];
submitted_at: string | null;
}
export interface TicketReviewer extends TicketActor {
review_comment: string | null;
reviewed_at: string | null;
}
export interface TicketAiResult {
detection_count: number;
available_defect_classes: string[];
}
export interface TicketTenant {
root_tenant_id: number;
organization_id: number;
}
export interface TicketTimestamps { export interface TicketTimestamps {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -160,25 +92,3 @@ export interface TicketOverviewDetail {
assignments?: TicketAssignmentSummary[]; assignments?: TicketAssignmentSummary[];
timestamps?: TicketTimestamps | null; timestamps?: TicketTimestamps | null;
} }
export interface TicketDetail {
id: string;
ticket_name?: string;
video_id: string;
chainage_id: string | null;
chainage_name: string | null;
status: TicketStatus;
defect_class: string;
defect_display_name: string;
assignment_status: TicketDefectClassTicketStatus;
status_metadata: TicketStatusMetadata | null;
video: TicketVideo | null;
uploader: TicketActor | null;
worker: TicketWorker | null;
repair: TicketRepair | null;
reviewer: TicketReviewer | null;
ai_result: TicketAiResult | null;
tenant: TicketTenant | null;
timestamps: TicketTimestamps;
history: TicketHistoryItem[];
}

View File

@@ -1,4 +1,3 @@
import type { TicketDetail } from './detail';
import type { TicketStatus } from './status'; import type { TicketStatus } from './status';
export interface SseTokenResponse { export interface SseTokenResponse {
@@ -22,7 +21,7 @@ export type TicketTableStatusEvent = {
message?: string; message?: string;
}; };
export type TicketDetailStatusEvent = Partial<TicketDetail> & { export type TicketDetailStatusEvent = {
type: 'ticket_status' | 'heartbeat'; type: 'ticket_status' | 'heartbeat';
kind?: 'snapshot' | 'transitioned'; kind?: 'snapshot' | 'transitioned';
id?: string; id?: string;

View File

@@ -4,3 +4,4 @@ export * from './detail';
export * from './actions'; export * from './actions';
export * from './defect-class'; export * from './defect-class';
export * from './events'; export * from './events';
export * from './timeline';

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