Compare commits
29 Commits
e894676d3b
...
global-ass
| Author | SHA1 | Date | |
|---|---|---|---|
| c13afa7554 | |||
| 8033450321 | |||
| 98de8e948b | |||
| 3697119656 | |||
| e268b55392 | |||
| da5b1009e3 | |||
| 3d04995f97 | |||
| d3069e1f25 | |||
| 47697b4421 | |||
| c8899c191e | |||
| 8ab9240b77 | |||
| f6436862c0 | |||
| 0ff5792670 | |||
| 0349891f52 | |||
| 7ee621b790 | |||
| 44e9dc4ec8 | |||
| d261404903 | |||
| bad9c50dbf | |||
| 6169bbdc28 | |||
| 0a4d9512cb | |||
| faefd34cd4 | |||
| 514b42ea91 | |||
| 9cd7186357 | |||
| 87fa04e1b5 | |||
| 2caeae7c91 | |||
| 44d3be5d27 | |||
| 58098af0fe | |||
| 1cbad4074c | |||
| 5e0d641575 |
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestStatus,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
const statusConfig: Record<
|
||||
TicketExtensionRequestStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
className:
|
||||
'border-amber-200 bg-amber-100 text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-300',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
className:
|
||||
'border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-300',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
className:
|
||||
'border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Cancelled',
|
||||
className: 'border-border bg-muted text-muted-foreground',
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: TicketExtensionRequestStatus }) {
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn('border', config.className)}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonCell({
|
||||
name,
|
||||
role,
|
||||
}: {
|
||||
name: string | null | undefined;
|
||||
role: string | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{name || '-'}</p>
|
||||
{role ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{role}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'ticket_name',
|
||||
header: 'Ticket / Assignment',
|
||||
size: 170,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.ticket_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assignment #{row.original.assignment_id}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'requested_by',
|
||||
header: 'Requested By',
|
||||
size: 170,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<PersonCell
|
||||
name={row.original.requested_by?.name}
|
||||
role={row.original.requested_by?.role.name}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
size: 125,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'current_due_at',
|
||||
header: 'Current Due Date',
|
||||
size: 175,
|
||||
cell: ({ row }) => formatDate(row.original.current_due_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'requested_due_at',
|
||||
header: 'Requested Due Date',
|
||||
size: 175,
|
||||
cell: ({ row }) => formatDate(row.original.requested_due_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'reason',
|
||||
header: 'Request Reason',
|
||||
size: 260,
|
||||
},
|
||||
{
|
||||
id: 'resolved_by',
|
||||
header: 'Resolved By',
|
||||
size: 170,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<PersonCell
|
||||
name={row.original.resolved_by?.name}
|
||||
role={row.original.resolved_by?.role.name}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'decision_note',
|
||||
header: 'Decision Note',
|
||||
size: 220,
|
||||
cell: ({ row }) => row.original.decision_note || '-',
|
||||
},
|
||||
{
|
||||
id: 'previous_requests',
|
||||
header: 'Previous Requests',
|
||||
size: 150,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">
|
||||
{row.original.history_meta.previous_request_count}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'resolved_at',
|
||||
header: 'Resolved On',
|
||||
size: 175,
|
||||
cell: ({ row }) => formatDate(row.original.resolved_at),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { ArrowRight, Check, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { TicketExtensionRequest } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import {
|
||||
MAX_EXTENSION_DECISION_NOTE_LENGTH,
|
||||
type ExtensionRequestDecisionAction,
|
||||
useExtensionRequestDecision,
|
||||
} from '../hooks/useExtensionRequestDecision';
|
||||
|
||||
interface ExtensionRequestDecisionDialogProps {
|
||||
request: TicketExtensionRequest | null;
|
||||
action: ExtensionRequestDecisionAction | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ExtensionRequestDecisionDialog({
|
||||
request,
|
||||
action,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ExtensionRequestDecisionDialogProps) {
|
||||
const handleSuccess = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||
const {
|
||||
note,
|
||||
setNote,
|
||||
noteError,
|
||||
selectedAction,
|
||||
isPending,
|
||||
isRejectConfirmationOpen,
|
||||
setIsRejectConfirmationOpen,
|
||||
submitDecision,
|
||||
requestRejection,
|
||||
resetDecision,
|
||||
} = useExtensionRequestDecision({
|
||||
ticketId: request?.ticket_id ?? '',
|
||||
assignmentId: request?.assignment_id ?? 0,
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resetDecision();
|
||||
}, [action, open, request?.id, resetDecision]);
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (isPending) return;
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
if (!request || !action) return null;
|
||||
|
||||
const isApproval = action === 'approve';
|
||||
const noteErrorId = `extension-manager-note-error-${request.id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl" showCloseButton={!isPending}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isApproval
|
||||
? 'Approve extension request'
|
||||
: 'Reject extension request'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review {request.ticket_name}, Assignment #{request.assignment_id}{' '}
|
||||
before confirming this decision.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current Due Date
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{formatDate(request.current_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="mt-5 size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requested Due Date
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{formatDate(request.requested_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Requested By</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{request.requested_by?.name ?? 'Assigned worker'}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">Reason</p>
|
||||
<p className="mt-1 leading-relaxed">{request.reason}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="extension-manager-note">
|
||||
Add Manager Comments
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isApproval
|
||||
? 'Optional for approval.'
|
||||
: 'Required for rejection.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="extension-manager-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="Add a note explaining your decision..."
|
||||
disabled={isPending}
|
||||
aria-invalid={Boolean(noteError)}
|
||||
aria-describedby={noteError ? noteErrorId : undefined}
|
||||
className="min-h-24 resize-none pb-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||
{note.length} / {MAX_EXTENSION_DECISION_NOTE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
{noteError ? (
|
||||
<p id={noteErrorId} className="text-xs text-destructive">
|
||||
{noteError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{isApproval ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
>
|
||||
{selectedAction === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
Approve Extension
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={requestRejection}
|
||||
>
|
||||
<X />
|
||||
Reject Request
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={isRejectConfirmationOpen}
|
||||
onOpenChange={setIsRejectConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to reject the extension request for{' '}
|
||||
{request.ticket_name}, Assignment #{request.assignment_id}? This
|
||||
decision cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
>
|
||||
{selectedAction === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : null}
|
||||
Yes, Reject
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ticketService } from '@/services/api';
|
||||
import type { ReviewTicketExtensionPayload } from '@/types';
|
||||
|
||||
import { ticketKeys } from '../../ticket/queries/ticketKeys';
|
||||
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
|
||||
|
||||
export const MAX_EXTENSION_DECISION_NOTE_LENGTH = 300;
|
||||
|
||||
export type ExtensionRequestDecisionAction = 'approve' | 'reject';
|
||||
|
||||
interface UseExtensionRequestDecisionOptions {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function useExtensionRequestDecision({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
onSuccess,
|
||||
}: UseExtensionRequestDecisionOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const [note, setNoteValue] = useState('');
|
||||
const [noteError, setNoteError] = useState<string | null>(null);
|
||||
const [selectedAction, setSelectedAction] =
|
||||
useState<ExtensionRequestDecisionAction | null>(null);
|
||||
const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
|
||||
useState(false);
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: (payload: ReviewTicketExtensionPayload) =>
|
||||
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
|
||||
onSuccess: (_response, payload) => {
|
||||
toast.success(
|
||||
payload.action === 'approve'
|
||||
? 'Extension request approved'
|
||||
: 'Extension request rejected',
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: extensionRequestKeys.lists(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: () => toast.error('Failed to review extension request'),
|
||||
});
|
||||
|
||||
const setNote = useCallback((value: string) => {
|
||||
const nextNote = value.slice(0, MAX_EXTENSION_DECISION_NOTE_LENGTH);
|
||||
setNoteValue(nextNote);
|
||||
if (nextNote.trim()) setNoteError(null);
|
||||
}, []);
|
||||
|
||||
const submitDecision = useCallback(
|
||||
async (action: ExtensionRequestDecisionAction) => {
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
if (action === 'reject' && !trimmedNote) {
|
||||
setNoteError('Manager comments are required to reject this request.');
|
||||
setIsRejectConfirmationOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAction(action);
|
||||
try {
|
||||
await reviewMutation.mutateAsync({
|
||||
action,
|
||||
...(trimmedNote ? { note: trimmedNote } : {}),
|
||||
});
|
||||
} catch {
|
||||
// The mutation displays the error toast and leaves the form open.
|
||||
} finally {
|
||||
setSelectedAction(null);
|
||||
}
|
||||
},
|
||||
[note, reviewMutation],
|
||||
);
|
||||
|
||||
const requestRejection = useCallback(() => {
|
||||
if (!note.trim()) {
|
||||
setNoteError('Manager comments are required to reject this request.');
|
||||
return;
|
||||
}
|
||||
|
||||
setNoteError(null);
|
||||
setIsRejectConfirmationOpen(true);
|
||||
}, [note]);
|
||||
|
||||
const resetDecision = useCallback(() => {
|
||||
setNoteValue('');
|
||||
setNoteError(null);
|
||||
setSelectedAction(null);
|
||||
setIsRejectConfirmationOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
note,
|
||||
setNote,
|
||||
noteError,
|
||||
selectedAction,
|
||||
isPending: reviewMutation.isPending,
|
||||
isRejectConfirmationOpen,
|
||||
setIsRejectConfirmationOpen,
|
||||
submitDecision,
|
||||
requestRejection,
|
||||
resetDecision,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
16
src/app/(modules)/extension-requests/layout.tsx
Normal file
16
src/app/(modules)/extension-requests/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/app/(modules)/extension-requests/page.tsx
Normal file
153
src/app/(modules)/extension-requests/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AlertCircle, CalendarClock, Check, RotateCcw, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { DataTableAction } from '@/components/data-table';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import type { TicketExtensionRequest } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { useExtensionRequestColumns } from './components/ExtensionRequestColumns';
|
||||
import { ExtensionRequestDecisionDialog } from './components/ExtensionRequestDecisionDialog';
|
||||
import { ExtensionRequestFilters } from './components/ExtensionRequestFilters';
|
||||
import { ExtensionRequestTable } from './components/ExtensionRequestTable';
|
||||
import type { ExtensionRequestDecisionAction } from './hooks/useExtensionRequestDecision';
|
||||
import { useExtensionRequestFilters } from './hooks/useExtensionRequestFilters';
|
||||
import { useExtensionRequestsQuery } from './hooks/useExtensionRequestQueries';
|
||||
|
||||
export default function ExtensionRequestsPage() {
|
||||
const router = useRouter();
|
||||
const [decision, setDecision] = useState<{
|
||||
request: TicketExtensionRequest;
|
||||
action: ExtensionRequestDecisionAction;
|
||||
} | null>(null);
|
||||
const {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
setLimit,
|
||||
ticketName,
|
||||
debouncedTicketName,
|
||||
setTicketName,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
clearFilters,
|
||||
} = useExtensionRequestFilters();
|
||||
const extensionRequestsQuery = useExtensionRequestsQuery({
|
||||
skip,
|
||||
limit,
|
||||
ticketName: debouncedTicketName,
|
||||
statusFilter,
|
||||
});
|
||||
const columns = useExtensionRequestColumns();
|
||||
const requests = extensionRequestsQuery.data?.items ?? [];
|
||||
const total = extensionRequestsQuery.data?.total ?? 0;
|
||||
|
||||
const actions = useMemo<DataTableAction<TicketExtensionRequest>[]>(
|
||||
() => [
|
||||
{
|
||||
label: 'Approve extension',
|
||||
icon: <Check className="text-emerald-600" />,
|
||||
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||
hidden: (request) => request.status !== 'pending',
|
||||
className:
|
||||
'border-emerald-200 hover:bg-emerald-50 dark:border-emerald-900 dark:hover:bg-emerald-950/50',
|
||||
onClick: (request) => setDecision({ request, action: 'approve' }),
|
||||
},
|
||||
{
|
||||
label: 'Reject extension',
|
||||
icon: <X className="text-destructive" />,
|
||||
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||
hidden: (request) => request.status !== 'pending',
|
||||
className:
|
||||
'border-destructive/30 hover:bg-destructive/5 hover:text-destructive',
|
||||
onClick: (request) => setDecision({ request, action: 'reject' }),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const openTicket = useCallback(
|
||||
(request: TicketExtensionRequest) => {
|
||||
router.push(ROUTES.TICKET_DETAIL(request.ticket_id));
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const filterToolbar = useMemo(
|
||||
() => (
|
||||
<ExtensionRequestFilters
|
||||
ticketName={ticketName}
|
||||
statusFilter={statusFilter}
|
||||
onTicketNameChange={setTicketName}
|
||||
onStatusChange={setStatusFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
),
|
||||
[clearFilters, setTicketName, setStatusFilter, statusFilter, ticketName],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5">
|
||||
<PageHeader
|
||||
title="Extension Requests"
|
||||
description="Track deadline extension requests across all ticket assignments."
|
||||
icon={CalendarClock}
|
||||
/>
|
||||
|
||||
{extensionRequestsQuery.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-48 flex-col items-center justify-center rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 text-center"
|
||||
>
|
||||
<AlertCircle className="mb-2 size-6 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Failed to load extension requests.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
disabled={extensionRequestsQuery.isFetching}
|
||||
onClick={() => extensionRequestsQuery.refetch()}
|
||||
>
|
||||
<RotateCcw
|
||||
className={
|
||||
extensionRequestsQuery.isFetching ? 'animate-spin' : undefined
|
||||
}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ExtensionRequestTable
|
||||
columns={columns}
|
||||
requests={requests}
|
||||
isLoading={extensionRequestsQuery.isLoading}
|
||||
toolbar={filterToolbar}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
onView={openTicket}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExtensionRequestDecisionDialog
|
||||
request={decision?.request ?? null}
|
||||
action={decision?.action ?? null}
|
||||
open={Boolean(decision)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDecision(null);
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -11,7 +11,7 @@ const ModulesLayout = ({
|
||||
}>) => {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<AppSidebar />
|
||||
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="scroll-stable min-w-0 flex-1 overflow-auto">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Edit, RotateCcw } from 'lucide-react';
|
||||
import { Edit, ShieldCheck, ShieldX } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
@@ -58,7 +58,12 @@ export function RoleTable({
|
||||
},
|
||||
{
|
||||
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
|
||||
icon: <RotateCcw className="size-4" />,
|
||||
icon: (role) =>
|
||||
role.effective_status ? (
|
||||
<ShieldX className="size-4 text-destructive" />
|
||||
) : (
|
||||
<ShieldCheck className="size-4 text-emerald-600" />
|
||||
),
|
||||
permission: PERMISSIONS.ROLE.DELETE,
|
||||
disabled: () => isStatusPending,
|
||||
onClick: onToggleStatus,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
|
||||
interface AssignTicketFormProps {
|
||||
ticketId: string;
|
||||
videoId?: string;
|
||||
selectedClassNames: string[];
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
@@ -37,7 +36,6 @@ interface AssignTicketFormProps {
|
||||
|
||||
export function AssignTicketForm({
|
||||
ticketId,
|
||||
videoId,
|
||||
selectedClassNames,
|
||||
startPoint,
|
||||
endPoint,
|
||||
@@ -49,7 +47,7 @@ export function AssignTicketForm({
|
||||
const [dueDate, setDueDate] = useState<Date>();
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticketId, videoId);
|
||||
const assignMutation = useAssignTicketMutation(ticketId);
|
||||
const today = useMemo(() => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -6,9 +6,8 @@ import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type {
|
||||
DetectionCoordinateClass,
|
||||
DetectionCoordinateItem,
|
||||
DetectionCoordinatesResponse,
|
||||
TicketAssignmentDetectionItem,
|
||||
TicketAssignmentDetectionsResponse,
|
||||
} from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -20,51 +19,81 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
import { AssignmentRangeActions } from './AssignmentRangeActions';
|
||||
|
||||
type AssignmentIssuesMapProps = {
|
||||
data?: DetectionCoordinatesResponse;
|
||||
data?: TicketAssignmentDetectionsResponse;
|
||||
isLoading: boolean;
|
||||
isFetchingMore: boolean;
|
||||
isError: boolean;
|
||||
hasMore: boolean;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
onLoadMore: () => void;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
type AssignmentMapItem = {
|
||||
id: number;
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
type AssignmentMapClass = {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
type ClientAssignmentIssuesMapProps = {
|
||||
items: DetectionCoordinateItem[];
|
||||
classes: DetectionCoordinateClass[];
|
||||
items: AssignmentMapItem[];
|
||||
classes: AssignmentMapClass[];
|
||||
selectedDetectionId: number | null;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSelectDetection: (detectionId: number) => void;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
};
|
||||
|
||||
function isValidCoordinate(item: DetectionCoordinateItem) {
|
||||
return (
|
||||
Number.isFinite(item.latitude) &&
|
||||
Number.isFinite(item.longitude) &&
|
||||
item.latitude >= -90 &&
|
||||
item.latitude <= 90 &&
|
||||
item.longitude >= -180 &&
|
||||
item.longitude <= 180
|
||||
);
|
||||
function toMapItem(
|
||||
item: TicketAssignmentDetectionItem,
|
||||
sequence: number,
|
||||
): AssignmentMapItem | null {
|
||||
const { latitude, longitude } = item.location;
|
||||
if (
|
||||
typeof latitude !== 'number' ||
|
||||
typeof longitude !== 'number' ||
|
||||
!Number.isFinite(latitude) ||
|
||||
!Number.isFinite(longitude) ||
|
||||
latitude < -90 ||
|
||||
latitude > 90 ||
|
||||
longitude < -180 ||
|
||||
longitude > 180
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.detection.id,
|
||||
class_name: item.detection.class_name,
|
||||
display_name: item.detection.display_name,
|
||||
latitude,
|
||||
longitude,
|
||||
sequence,
|
||||
};
|
||||
}
|
||||
|
||||
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
async () => {
|
||||
const { CircleMarker, MapContainer, Popup, TileLayer, Tooltip, useMap } =
|
||||
await import('react-leaflet');
|
||||
const {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
useMap,
|
||||
} = await import('react-leaflet');
|
||||
const { canvas } = await import('leaflet');
|
||||
|
||||
function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) {
|
||||
function FitMapToIssues({ items }: { items: AssignmentMapItem[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,8 +125,6 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSelectDetection,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
}: ClientAssignmentIssuesMapProps) {
|
||||
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
|
||||
const displayNames = new Map(
|
||||
@@ -107,6 +134,27 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
items[0].latitude,
|
||||
items[0].longitude,
|
||||
];
|
||||
const orderedItems = [...items].sort(
|
||||
(first, second) =>
|
||||
first.sequence - second.sequence || first.id - second.id,
|
||||
);
|
||||
const startIndex = orderedItems.findIndex(
|
||||
(item) => item.id === startPoint?.detectionId,
|
||||
);
|
||||
const endIndex = orderedItems.findIndex(
|
||||
(item) => item.id === endPoint?.detectionId,
|
||||
);
|
||||
const hasSelectedRange = startIndex >= 0 && endIndex >= 0;
|
||||
const rangeItems = hasSelectedRange
|
||||
? orderedItems.slice(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex) + 1,
|
||||
)
|
||||
: [];
|
||||
const rangeDetectionIds = new Set(rangeItems.map((item) => item.id));
|
||||
const rangePath = rangeItems.map(
|
||||
(item) => [item.latitude, item.longitude] as [number, number],
|
||||
);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
@@ -119,7 +167,30 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
attribution="© OpenStreetMap contributors"
|
||||
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<FitMapToIssues items={items} />
|
||||
<FitMapToIssues items={hasSelectedRange ? rangeItems : items} />
|
||||
|
||||
{rangePath.length >= 2 ? (
|
||||
<>
|
||||
<Polyline
|
||||
positions={rangePath}
|
||||
interactive={false}
|
||||
pathOptions={{
|
||||
color: '#3b82f6',
|
||||
opacity: 0.18,
|
||||
weight: 26,
|
||||
}}
|
||||
/>
|
||||
<Polyline
|
||||
positions={rangePath}
|
||||
interactive={false}
|
||||
pathOptions={{
|
||||
color: '#2563eb',
|
||||
opacity: 0.9,
|
||||
weight: 4,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{items.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
@@ -127,35 +198,37 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
const isSelected = selectedDetectionId === item.id;
|
||||
const isStart = startPoint?.detectionId === item.id;
|
||||
const isEnd = endPoint?.detectionId === item.id;
|
||||
const rangeLabel = isStart ? 'A' : isEnd ? 'B' : null;
|
||||
const rangeLabel = isStart ? 'Start' : isEnd ? 'End' : null;
|
||||
const rangeColor = isStart
|
||||
? '#16a34a'
|
||||
: isEnd
|
||||
? '#dc2626'
|
||||
: visual.boundingBoxColor;
|
||||
const isRangeIssue = isStart || isEnd;
|
||||
const isInsideRange = rangeDetectionIds.has(item.id);
|
||||
const displayName =
|
||||
displayNames.get(item.class_name) ??
|
||||
item.display_name ??
|
||||
item.class_name.replaceAll('_', ' ');
|
||||
const point: AssignmentRangePoint = {
|
||||
detectionId: item.id,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
};
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={item.id}
|
||||
center={[item.latitude, item.longitude]}
|
||||
renderer={markerRenderer}
|
||||
radius={isSelected || isRangeIssue ? 11 : 8}
|
||||
radius={isSelected || isRangeIssue ? 11 : isInsideRange ? 9 : 7}
|
||||
pathOptions={{
|
||||
color:
|
||||
isSelected || isRangeIssue
|
||||
? '#ffffff'
|
||||
: visual.boundingBoxColor,
|
||||
fillColor: rangeColor,
|
||||
fillOpacity: isSelected || isRangeIssue ? 1 : 0.82,
|
||||
fillOpacity:
|
||||
isSelected || isRangeIssue
|
||||
? 1
|
||||
: hasSelectedRange && !isInsideRange
|
||||
? 0.22
|
||||
: 0.82,
|
||||
opacity: hasSelectedRange && !isInsideRange ? 0.3 : 1,
|
||||
weight: isSelected || isRangeIssue ? 4 : 2,
|
||||
}}
|
||||
eventHandlers={{
|
||||
@@ -183,23 +256,17 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
</p>
|
||||
</div>
|
||||
{rangeLabel ? (
|
||||
<span
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: rangeColor }}
|
||||
aria-label={isStart ? 'Start issue' : 'End issue'}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-semibold">
|
||||
<span
|
||||
className="size-3 rounded-full"
|
||||
style={{ backgroundColor: rangeColor }}
|
||||
/>
|
||||
{rangeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="!m-0 space-y-2 py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Confidence</dt>
|
||||
<dd className="!m-0 font-medium tabular-nums">
|
||||
{Math.round(item.confidence * 100)}%
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Latitude</dt>
|
||||
<dd className="!m-0 font-mono text-xs tabular-nums">
|
||||
@@ -213,22 +280,17 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<AssignmentRangeActions
|
||||
point={point}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSetStart={onSetStart}
|
||||
onSetEnd={onSetEnd}
|
||||
stretch
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
{rangeLabel ? (
|
||||
<Tooltip permanent direction="center" opacity={1}>
|
||||
{rangeLabel}
|
||||
<Tooltip
|
||||
permanent
|
||||
direction="top"
|
||||
offset={[0, -10]}
|
||||
opacity={1}
|
||||
className="range-point-label"
|
||||
>
|
||||
<span style={{ color: rangeColor }}>{rangeLabel}</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</CircleMarker>
|
||||
@@ -247,25 +309,38 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
export function AssignmentIssuesMap({
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingMore,
|
||||
isError,
|
||||
hasMore,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
}: AssignmentIssuesMapProps) {
|
||||
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const validItems = useMemo(
|
||||
() => (data?.items ?? []).filter(isValidCoordinate),
|
||||
() =>
|
||||
(data?.items ?? []).flatMap((item, index) => {
|
||||
const mapItem = toMapItem(item, index);
|
||||
return mapItem ? [mapItem] : [];
|
||||
}),
|
||||
[data?.items],
|
||||
);
|
||||
const total = data?.total ?? 0;
|
||||
const loadedCount = Math.min(data?.items.length ?? 0, total);
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Map(
|
||||
validItems.map((item) => [
|
||||
item.class_name,
|
||||
{
|
||||
class_name: item.class_name,
|
||||
display_name: item.display_name,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
),
|
||||
[validItems],
|
||||
);
|
||||
const pageItemCount = data?.items.length ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -287,7 +362,7 @@ export function AssignmentIssuesMap({
|
||||
<div>
|
||||
<CardTitle className="text-base">Issues on Map</CardTitle>
|
||||
<CardDescription>
|
||||
Click a marker, then choose Start or End.
|
||||
Select Start and End in the table to preview the covered range.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,13 +404,11 @@ export function AssignmentIssuesMap({
|
||||
<div className="h-[440px] min-w-0 bg-muted">
|
||||
<ClientAssignmentIssuesMap
|
||||
items={validItems}
|
||||
classes={data?.classes ?? []}
|
||||
classes={classes}
|
||||
selectedDetectionId={selectedDetectionId}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSelectDetection={setSelectedDetectionId}
|
||||
onSetStart={onSetStart}
|
||||
onSetEnd={onSetEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -344,7 +417,7 @@ export function AssignmentIssuesMap({
|
||||
aria-label="Map legend"
|
||||
>
|
||||
<span className="text-sm font-medium">Map legend</span>
|
||||
{(data?.classes ?? []).map((item) => {
|
||||
{classes.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
@@ -366,27 +439,33 @@ export function AssignmentIssuesMap({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{startPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="size-4 rounded-full border-2 border-white bg-green-600 shadow-sm" />
|
||||
<span>Start</span>
|
||||
</div>
|
||||
) : null}
|
||||
{endPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="size-4 rounded-full border-2 border-white bg-red-600 shadow-sm" />
|
||||
<span>End</span>
|
||||
</div>
|
||||
) : null}
|
||||
{startPoint && endPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="relative h-4 w-8 rounded-full bg-blue-500/20">
|
||||
<span className="absolute top-1/2 right-0 left-0 h-0.5 -translate-y-1/2 bg-blue-600" />
|
||||
</span>
|
||||
<span>Coverage area</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t px-4 py-3">
|
||||
<div className="border-t px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {loadedCount} of {total}
|
||||
{validItems.length} mapped from {pageItemCount} cached{' '}
|
||||
{pageItemCount === 1 ? 'issue' : 'issues'}
|
||||
</span>
|
||||
{hasMore ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingMore}
|
||||
>
|
||||
{isFetchingMore ? 'Loading...' : 'Load more issues'}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
All issues are shown
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { FilterX } from 'lucide-react';
|
||||
|
||||
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TicketAssignmentDetectionsResponse } from '@/types';
|
||||
|
||||
import {
|
||||
useDetectionCoordinatesQuery,
|
||||
useTicketDetectionsQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
import { useTicketAssignmentDetectionsQuery } from '../../../hooks/useTicketQueries';
|
||||
import {
|
||||
formatCompactRangeCoordinate,
|
||||
formatRangeCoordinate,
|
||||
@@ -22,7 +20,6 @@ import { useIssueColumns } from './IssueColumns';
|
||||
import { IssueTable } from './IssueTable';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const MAP_PAGE_SIZE = 50;
|
||||
|
||||
interface IssueTypeOption {
|
||||
class_name: string;
|
||||
@@ -31,9 +28,10 @@ interface IssueTypeOption {
|
||||
}
|
||||
|
||||
interface ChooseIssuesSectionProps {
|
||||
videoId?: string;
|
||||
ticketId: string;
|
||||
issueTypes: IssueTypeOption[];
|
||||
selectedIssueTypes: string[];
|
||||
cacheRevision: number;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSelectedIssueTypesChange: (values: string[]) => void;
|
||||
@@ -42,9 +40,10 @@ interface ChooseIssuesSectionProps {
|
||||
}
|
||||
|
||||
export function ChooseIssuesSection({
|
||||
videoId,
|
||||
ticketId,
|
||||
issueTypes,
|
||||
selectedIssueTypes,
|
||||
cacheRevision,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSelectedIssueTypesChange,
|
||||
@@ -84,60 +83,86 @@ export function ChooseIssuesSection({
|
||||
);
|
||||
const handleIssueTypesChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setSkip(0);
|
||||
onSelectedIssueTypesChange(values);
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
},
|
||||
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
|
||||
);
|
||||
useEffect(() => {
|
||||
const hasActiveFilters =
|
||||
selectedIssueTypes.length > 0 || startPoint !== null || endPoint !== null;
|
||||
const clearFilters = useCallback(() => {
|
||||
setSkip(0);
|
||||
}, [selectedIssueTypes]);
|
||||
onSelectedIssueTypesChange([]);
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
}, [onEndPointChange, onSelectedIssueTypesChange, onStartPointChange]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
assignment_status: 'unassigned' as const,
|
||||
skip,
|
||||
limit,
|
||||
class_name:
|
||||
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
|
||||
sort: 'timestamp_asc',
|
||||
}),
|
||||
[limit, selectedIssueTypes, skip],
|
||||
);
|
||||
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
|
||||
const mapQueryParams = useMemo(
|
||||
() => ({
|
||||
limit: MAP_PAGE_SIZE,
|
||||
class_name:
|
||||
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
|
||||
assignment_status: 'unassigned' as const,
|
||||
}),
|
||||
[selectedIssueTypes],
|
||||
);
|
||||
const coordinatesQuery = useDetectionCoordinatesQuery(
|
||||
videoId,
|
||||
mapQueryParams,
|
||||
const detectionsQuery = useTicketAssignmentDetectionsQuery(
|
||||
ticketId,
|
||||
queryParams,
|
||||
);
|
||||
const result = detectionsQuery.data;
|
||||
const mapData = useMemo(() => {
|
||||
const pages = coordinatesQuery.data?.pages;
|
||||
const mapCacheKey = useMemo(
|
||||
() => JSON.stringify([ticketId, selectedIssueTypes, cacheRevision]),
|
||||
[cacheRevision, selectedIssueTypes, ticketId],
|
||||
);
|
||||
const [mapCache, setMapCache] = useState<
|
||||
Record<string, TicketAssignmentDetectionsResponse>
|
||||
>({});
|
||||
|
||||
if (!pages?.length) return undefined;
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
|
||||
const latestPage = pages[pages.length - 1];
|
||||
setMapCache((current) => {
|
||||
const itemsById = new Map(
|
||||
(current[mapCacheKey]?.items ?? []).map((item) => [
|
||||
item.detection.id,
|
||||
item,
|
||||
]),
|
||||
);
|
||||
result.items.forEach((item) => itemsById.set(item.detection.id, item));
|
||||
|
||||
return {
|
||||
...latestPage,
|
||||
items: pages.flatMap((page) => page.items),
|
||||
truncated: Boolean(coordinatesQuery.hasNextPage),
|
||||
};
|
||||
}, [coordinatesQuery.data?.pages, coordinatesQuery.hasNextPage]);
|
||||
return {
|
||||
...current,
|
||||
[mapCacheKey]: {
|
||||
...result,
|
||||
skip: 0,
|
||||
limit: itemsById.size,
|
||||
items: Array.from(itemsById.values()),
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [mapCacheKey, result]);
|
||||
|
||||
const mapData = mapCache[mapCacheKey];
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<Card size="sm" aria-label="Issue filters">
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<FilterX />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -177,40 +202,10 @@ export function ChooseIssuesSection({
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{startPoint !== null || endPoint !== null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label="Clear coordinate range"
|
||||
onClick={() => {
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AssignmentIssuesMap
|
||||
data={mapData}
|
||||
isLoading={coordinatesQuery.isLoading}
|
||||
isFetchingMore={coordinatesQuery.isFetchingNextPage}
|
||||
isError={coordinatesQuery.isError}
|
||||
hasMore={Boolean(coordinatesQuery.hasNextPage)}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSetStart={handleSetStart}
|
||||
onSetEnd={handleSetEnd}
|
||||
onLoadMore={() => void coordinatesQuery.fetchNextPage()}
|
||||
onRetry={() => void coordinatesQuery.refetch()}
|
||||
/>
|
||||
|
||||
<IssueTable
|
||||
columns={columns}
|
||||
issues={result?.items ?? []}
|
||||
@@ -223,6 +218,15 @@ export function ChooseIssuesSection({
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
|
||||
<AssignmentIssuesMap
|
||||
data={mapData}
|
||||
isLoading={detectionsQuery.isLoading && !mapData}
|
||||
isError={detectionsQuery.isError && !mapData}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onRetry={() => void detectionsQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,17 +5,11 @@ import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
import type { TicketAssignmentDetectionItem } from '@/types';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
import { AssignmentRangeActions } from './AssignmentRangeActions';
|
||||
|
||||
function formatTimestamp(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface UseIssueColumnsOptions {
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
@@ -28,7 +22,7 @@ export function useIssueColumns({
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
}: UseIssueColumnsOptions): ColumnDef<DetectionResultItem>[] {
|
||||
}: UseIssueColumnsOptions): ColumnDef<TicketAssignmentDetectionItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -89,13 +83,6 @@ export function useIssueColumns({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
size: 130,
|
||||
cell: ({ row }) =>
|
||||
formatTimestamp(row.original.frame.timestamp_seconds),
|
||||
},
|
||||
{
|
||||
id: 'range_actions',
|
||||
header: 'Set range',
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
import type { TicketAssignmentDetectionItem } from '@/types';
|
||||
|
||||
interface IssueTableProps {
|
||||
columns: ColumnDef<DetectionResultItem>[];
|
||||
issues: DetectionResultItem[];
|
||||
columns: ColumnDef<TicketAssignmentDetectionItem>[];
|
||||
issues: TicketAssignmentDetectionItem[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
toolbar: ReactNode;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -42,9 +43,9 @@ export default function TicketAssignmentPage() {
|
||||
);
|
||||
const [endPoint, setEndPoint] = useState<AssignmentRangePoint | null>(null);
|
||||
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
|
||||
const [assignmentRevision, setAssignmentRevision] = useState(0);
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const overview = overviewQuery.data;
|
||||
const videoId = overview?.video_id ?? overview?.video?.id ?? undefined;
|
||||
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
|
||||
const backToTicket = () => router.push(ticketDetailUrl);
|
||||
|
||||
@@ -78,7 +79,7 @@ export default function TicketAssignmentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const ticketLabel = overview.ticket_name || overview.id;
|
||||
const ticketName = overview.ticket_name || 'Unnamed ticket';
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
@@ -93,22 +94,27 @@ export default function TicketAssignmentPage() {
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Assign ticket
|
||||
Assign Ticket
|
||||
</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
Ticket: {ticketLabel}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full font-mono text-xs"
|
||||
title={ticketName}
|
||||
>
|
||||
<span className="truncate">{ticketName}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-stretch xl:overflow-hidden">
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
<ChooseIssuesSection
|
||||
videoId={videoId}
|
||||
ticketId={ticketId}
|
||||
issueTypes={overview.ai_result.detections_by_class}
|
||||
selectedIssueTypes={selectedIssueTypes}
|
||||
cacheRevision={assignmentRevision}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSelectedIssueTypesChange={setSelectedIssueTypes}
|
||||
@@ -144,13 +150,13 @@ export default function TicketAssignmentPage() {
|
||||
>
|
||||
<AssignTicketForm
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
selectedClassNames={selectedIssueTypes}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onAssigned={() => {
|
||||
setStartPoint(null);
|
||||
setEndPoint(null);
|
||||
setAssignmentRevision((revision) => revision + 1);
|
||||
}}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { BriefcaseBusiness, CalendarClock, ListChecks } from 'lucide-react';
|
||||
import {
|
||||
BriefcaseBusiness,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
Images,
|
||||
ListChecks,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -20,9 +31,11 @@ import type { TicketActor, TicketAssignmentSummary } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
|
||||
import { TicketDetectionPreview } from './TicketDetectionPreview';
|
||||
|
||||
interface TicketAssignmentsCardProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
initialAssignments?: TicketAssignmentSummary[];
|
||||
selectedAssignmentId?: number;
|
||||
onAssignmentChange: (assignmentId: number) => void;
|
||||
@@ -87,7 +100,7 @@ function AssignmentProgress({
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentLot({
|
||||
function AssignmentOverview({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: TicketAssignmentSummary;
|
||||
@@ -109,7 +122,7 @@ function AssignmentLot({
|
||||
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Lot {assignment.id}
|
||||
Assignment {assignment.id}
|
||||
</p>
|
||||
<PersonInfo person={worker} />
|
||||
</div>
|
||||
@@ -138,6 +151,7 @@ function AssignmentLot({
|
||||
|
||||
export function TicketAssignmentsCard({
|
||||
ticketId,
|
||||
videoId,
|
||||
initialAssignments,
|
||||
selectedAssignmentId,
|
||||
onAssignmentChange,
|
||||
@@ -146,7 +160,9 @@ export function TicketAssignmentsCard({
|
||||
ticketId,
|
||||
initialAssignments,
|
||||
);
|
||||
const assignments = assignmentsQuery.data?.items ?? [];
|
||||
const assignments = (assignmentsQuery.data?.items ?? []).filter(
|
||||
(assignment) => assignment.item_count > 0,
|
||||
);
|
||||
const selectedAssignment =
|
||||
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
|
||||
assignments[0];
|
||||
@@ -171,14 +187,17 @@ export function TicketAssignmentsCard({
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BriefcaseBusiness className="size-5 text-primary" />
|
||||
Assignment lots
|
||||
Work Assignments
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Live ownership, criteria, and repair progress for this ticket.
|
||||
Issues grouped by assignee, scope, and repair progress.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{assignmentsQuery.data ? (
|
||||
<Badge variant="secondary">{assignmentsQuery.data.total}</Badge>
|
||||
<Badge variant="default">
|
||||
{assignments.length}{' '}
|
||||
{assignments.length === 1 ? 'Assignment' : 'Assignments'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -193,7 +212,7 @@ export function TicketAssignmentsCard({
|
||||
) : assignmentsQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Unable to load assignment lots.
|
||||
Unable to load work assignments.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -208,7 +227,7 @@ export function TicketAssignmentsCard({
|
||||
) : selectedAssignment ? (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<nav
|
||||
aria-label="Assignment lots"
|
||||
aria-label="Work assignments"
|
||||
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
|
||||
>
|
||||
<div
|
||||
@@ -237,7 +256,7 @@ export function TicketAssignmentsCard({
|
||||
)}
|
||||
onClick={() => onAssignmentChange(assignment.id)}
|
||||
>
|
||||
Lot {assignment.id}
|
||||
Assignment {assignment.id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -250,14 +269,44 @@ export function TicketAssignmentsCard({
|
||||
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssignmentLot assignment={selectedAssignment} />
|
||||
<AssignmentOverview assignment={selectedAssignment} />
|
||||
{videoId ? (
|
||||
<Collapsible
|
||||
key={selectedAssignment.id}
|
||||
className="group mt-4 overflow-hidden rounded-lg border"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-between rounded-none px-4 py-3"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Images className="size-4 text-primary" />
|
||||
Assigned issue images
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t p-4">
|
||||
<TicketDetectionPreview
|
||||
key={`${videoId}:${selectedAssignment.id}`}
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
assignmentId={selectedAssignment.id}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium">No assignments yet</p>
|
||||
<p className="text-sm font-medium">No work assignments yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Assignment lots will appear here after work is allocated.
|
||||
Work assignments will appear here after issues are allocated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,44 +3,63 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketDetail, TicketOverviewDetail } from '@/types';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||
|
||||
export function TicketDetailHeader({
|
||||
ticket,
|
||||
classDetail,
|
||||
backHref,
|
||||
}: {
|
||||
ticket: TicketOverviewDetail;
|
||||
classDetail?: TicketDetail;
|
||||
backHref: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const ticketLabel = ticket.ticket_name || ticket.id;
|
||||
const ticketName = ticket.ticket_name || 'Unnamed ticket';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1>
|
||||
<p className="text-xs ">Ticket: {ticketLabel}</p>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => router.push(backHref)}
|
||||
aria-label="Back to ticket list"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Ticket Detail
|
||||
</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full font-mono text-xs"
|
||||
title={ticketName}
|
||||
>
|
||||
<span className="truncate">{ticketName}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{classDetail?.assignment_status === 'under_review' ? (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
|
||||
<OpenRepairReviewAction ticket={classDetail} />
|
||||
</PermissionGuard>
|
||||
) : null}
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
|
||||
<OpenRepairReviewAction
|
||||
ticketId={ticket.id}
|
||||
hasPendingReview={ticket.has_pending_review}
|
||||
pendingReviewCount={ticket.pending_review_count}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<AssignTicketAction ticketId={ticket.id} />
|
||||
</PermissionGuard>
|
||||
<Button variant="secondary" onClick={() => router.push('/ticket')}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||
@@ -17,13 +16,16 @@ import {
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||
|
||||
interface TicketClassDetectionPreviewProps {
|
||||
interface TicketDetectionPreviewProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
assignmentId?: number;
|
||||
totalCount?: number;
|
||||
assignmentStatus?: 'unassigned';
|
||||
onDetectionCountChange?: (count: number | undefined) => void;
|
||||
}
|
||||
|
||||
const DETECTION_PAGE_SIZE = 10;
|
||||
|
||||
function DetectionMetadataBar({
|
||||
items,
|
||||
}: {
|
||||
@@ -53,7 +55,7 @@ function DetectionMetadataBar({
|
||||
);
|
||||
}
|
||||
|
||||
function TicketClassDetectionPreviewSkeleton() {
|
||||
function TicketDetectionPreviewSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
@@ -92,38 +94,47 @@ function TicketClassDetectionPreviewSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketClassDetectionPreview({
|
||||
export function TicketDetectionPreview({
|
||||
ticketId,
|
||||
videoId,
|
||||
assignmentId,
|
||||
totalCount,
|
||||
}: TicketClassDetectionPreviewProps) {
|
||||
assignmentStatus,
|
||||
onDetectionCountChange,
|
||||
}: TicketDetectionPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
const isUnassignedPreview = assignmentStatus === 'unassigned';
|
||||
const pageStart =
|
||||
Math.floor(currentIndex / DETECTION_PAGE_SIZE) * DETECTION_PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(0);
|
||||
}, [assignmentId, videoId]);
|
||||
}, [assignmentId, assignmentStatus, videoId]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
skip: currentIndex,
|
||||
limit: 1,
|
||||
assignment_id: assignmentId,
|
||||
skip: pageStart,
|
||||
limit: DETECTION_PAGE_SIZE,
|
||||
assignment_id: isUnassignedPreview ? undefined : assignmentId,
|
||||
assignment_status: isUnassignedPreview
|
||||
? ('unassigned' as const)
|
||||
: undefined,
|
||||
sort: 'timestamp_asc',
|
||||
}),
|
||||
[assignmentId, currentIndex],
|
||||
[assignmentId, isUnassignedPreview, pageStart],
|
||||
);
|
||||
|
||||
const detectionsQuery = useTicketDetectionsQuery(
|
||||
videoId ?? undefined,
|
||||
queryParams,
|
||||
{ keepPreviousPage: true },
|
||||
);
|
||||
const detectionResult = detectionsQuery.data;
|
||||
const activeDetection = detectionResult?.items[0];
|
||||
const activeDetection = detectionsQuery.isPlaceholderData
|
||||
? undefined
|
||||
: detectionResult?.items[currentIndex - pageStart];
|
||||
const confirmedDetectionCount = detectionResult?.total;
|
||||
const detectionCount =
|
||||
detectionResult?.total ?? (assignmentId ? 0 : (totalCount ?? 0));
|
||||
const detectionCount = confirmedDetectionCount ?? 0;
|
||||
const activeVisual = getDefectVisual(
|
||||
activeDetection?.detection.class_name ?? '',
|
||||
);
|
||||
@@ -133,7 +144,6 @@ export function TicketClassDetectionPreview({
|
||||
const discardMutation = useDiscardDetectionMutation(
|
||||
ticketId,
|
||||
videoId ?? undefined,
|
||||
activeDetection?.detection.class_name,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -152,8 +162,18 @@ export function TicketClassDetectionPreview({
|
||||
}
|
||||
}, [confirmedDetectionCount, currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
onDetectionCountChange?.(detectionCount);
|
||||
}, [detectionCount, onDetectionCountChange]);
|
||||
|
||||
const isNavigationDisabled =
|
||||
detectionCount === 0 || detectionsQuery.isLoading;
|
||||
detectionCount === 0 ||
|
||||
detectionsQuery.isLoading ||
|
||||
detectionsQuery.isPlaceholderData;
|
||||
const isInitialLoading =
|
||||
detectionsQuery.isLoading || detectionsQuery.isPlaceholderData;
|
||||
const isError = detectionsQuery.isError;
|
||||
const retry = () => void detectionsQuery.refetch();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -169,16 +189,8 @@ export function TicketClassDetectionPreview({
|
||||
className={cn('size-5', activeVisual.colorClassName)}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3>{activeDisplayName}</h3>
|
||||
<Badge variant="secondary" className="rounded-lg">
|
||||
{detectionCount} Detections
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Review all detections in ascending timestamp order.
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<h3>{activeDisplayName}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,28 +231,27 @@ export function TicketClassDetectionPreview({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detectionsQuery.isLoading && !detectionsQuery.data ? (
|
||||
<TicketClassDetectionPreviewSkeleton />
|
||||
) : detectionsQuery.isError ? (
|
||||
{isInitialLoading ? (
|
||||
<TicketDetectionPreviewSkeleton />
|
||||
) : isError ? (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<AlertTriangle className="size-6 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Failed to load detection preview.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void detectionsQuery.refetch()}
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : !activeDetection || !detectionResult ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No detections are available for this issue.
|
||||
{detectionCount > 0
|
||||
? 'The selected detection details are unavailable. Try refreshing the preview.'
|
||||
: isUnassignedPreview
|
||||
? 'No unassigned issues remain on this ticket.'
|
||||
: 'No detections are available for this issue.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -287,30 +298,34 @@ export function TicketClassDetectionPreview({
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setIsDiscardDialogOpen(true)}
|
||||
>
|
||||
<Trash2 />
|
||||
Discard detection
|
||||
</Button>
|
||||
</div>
|
||||
{isUnassignedPreview ? (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setIsDiscardDialogOpen(true)}
|
||||
>
|
||||
<Trash2 />
|
||||
Discard detection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DetectionDiscardDialog
|
||||
open={isDiscardDialogOpen}
|
||||
detectionLabel={activeDetection.detection.display_name}
|
||||
isPending={discardMutation.isPending}
|
||||
onOpenChange={setIsDiscardDialogOpen}
|
||||
onSubmit={async (payload) => {
|
||||
await discardMutation.mutateAsync({
|
||||
detectionId: activeDetection.detection.id,
|
||||
payload,
|
||||
});
|
||||
setIsDiscardDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
<DetectionDiscardDialog
|
||||
open={isDiscardDialogOpen}
|
||||
detectionLabel={activeDetection.detection.display_name}
|
||||
isPending={discardMutation.isPending}
|
||||
onOpenChange={setIsDiscardDialogOpen}
|
||||
onSubmit={async (payload) => {
|
||||
await discardMutation.mutateAsync({
|
||||
detectionId: activeDetection.detection.id,
|
||||
payload,
|
||||
});
|
||||
setIsDiscardDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
import { TicketDetectionPreview } from './TicketDetectionPreview';
|
||||
|
||||
interface TicketDetectionPreviewCardProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
assignmentId: number;
|
||||
}
|
||||
|
||||
export function TicketDetectionPreviewCard({
|
||||
ticketId,
|
||||
videoId,
|
||||
assignmentId,
|
||||
}: TicketDetectionPreviewCardProps) {
|
||||
if (!videoId) return null;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="p-5">
|
||||
<div className="min-w-0">
|
||||
<TicketDetectionPreview
|
||||
key={`${videoId}:${assignmentId}`}
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
assignmentId={assignmentId}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { History, MessageSquareText } from 'lucide-react';
|
||||
import { History, MessageSquareText, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketDetail, TicketHistoryItem, TicketHistorySource } from '@/types';
|
||||
import type {
|
||||
TicketTimelineEventType,
|
||||
TicketTimelineItem,
|
||||
TicketTimelineNoteKind,
|
||||
TicketTimelineSource,
|
||||
TicketTimelineUser,
|
||||
} from '@/types';
|
||||
import { formatTimelineDateTime } from '@/utils/date';
|
||||
import { getPersonLabel } from '@/utils/person';
|
||||
|
||||
import { useTicketTimelineQuery } from '../../hooks/useTicketQueries';
|
||||
import { TicketTimelineSkeleton } from './TicketTimelineSkeleton';
|
||||
|
||||
function TimelineDate({ value }: { value: string }) {
|
||||
const { date, time } = formatTimelineDateTime(value);
|
||||
|
||||
return (
|
||||
<time dateTime={value} className="block tabular-nums">
|
||||
<span className="block text-sm font-semibold text-foreground">{date}</span>
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{date}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{time}</span>
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryNote({ item }: { item: TicketHistoryItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
function getNoteKindLabel(kind: TicketTimelineNoteKind) {
|
||||
switch (kind) {
|
||||
case 'system_note':
|
||||
return 'System note';
|
||||
case 'repair_note':
|
||||
return 'Repair note';
|
||||
case 'review_comment':
|
||||
return 'Review comment';
|
||||
default:
|
||||
return 'Note';
|
||||
}
|
||||
}
|
||||
|
||||
function HistoryNote({ item }: { item: TicketTimelineItem }) {
|
||||
const note = item.note;
|
||||
if (!note) return null;
|
||||
|
||||
const noteText = note.text.trim();
|
||||
if (!noteText) return null;
|
||||
|
||||
return (
|
||||
@@ -30,7 +58,7 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) {
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
|
||||
<MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
|
||||
<span>
|
||||
Note by {getPersonLabel(item.note?.author ?? item.actor)}
|
||||
{getNoteKindLabel(note.kind)} by {getPersonLabel(note.author)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90">
|
||||
@@ -40,22 +68,21 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getSourceLabel(source: TicketHistorySource) {
|
||||
function getSourceLabel(source: TicketTimelineSource) {
|
||||
return source === 'system' ? 'System Generated' : 'User Action';
|
||||
}
|
||||
|
||||
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
function SystemHistoryEntry({ item }: { item: TicketTimelineItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
|
||||
<Badge
|
||||
|
||||
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
|
||||
>
|
||||
<Badge className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
|
||||
{getSourceLabel(item.source)}
|
||||
</Badge>
|
||||
<h3 className="mt-2 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||
<h3 className="mt-2 text-sm font-semibold text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
{noteText ? (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{noteText}
|
||||
@@ -65,17 +92,52 @@ function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
function isSameUser(actor: TicketTimelineUser, target: TicketTimelineUser) {
|
||||
if (actor.user_id !== null && target.user_id !== null) {
|
||||
return actor.user_id === target.user_id;
|
||||
}
|
||||
|
||||
const actorEmail = actor.email?.trim().toLowerCase();
|
||||
const targetEmail = target.email?.trim().toLowerCase();
|
||||
if (actorEmail && targetEmail) return actorEmail === targetEmail;
|
||||
|
||||
return (
|
||||
Boolean(actor.name?.trim()) &&
|
||||
actor.name?.trim().toLowerCase() === target.name?.trim().toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function getTargetLabel(eventType: TicketTimelineEventType | null) {
|
||||
switch (eventType) {
|
||||
case 'assignment_added':
|
||||
return 'Assigned to';
|
||||
case 'assignment_replaced':
|
||||
return 'Replacement worker';
|
||||
case 'assignment_updated':
|
||||
return 'Assigned worker';
|
||||
case 'assignment_deleted':
|
||||
return 'Removed worker';
|
||||
default:
|
||||
return 'Affected worker';
|
||||
}
|
||||
}
|
||||
|
||||
function UserHistoryEntry({ item }: { item: TicketTimelineItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
const isAssigned = item.event_type === 'assigned';
|
||||
const showTargetUser =
|
||||
item.target_user && !isSameUser(item.actor, item.target_user);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<PersonInfo person={item.actor} />
|
||||
{isAssigned && item.target_user ? (
|
||||
{showTargetUser && item.target_user ? (
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Assigned to</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getTargetLabel(item.event_type)}
|
||||
</p>
|
||||
<PersonInfo person={item.target_user} />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -84,17 +146,54 @@ function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const history = ticket.history ?? [];
|
||||
function TimelineHeader() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketHistoryCard({ ticketId }: { ticketId: string }) {
|
||||
const timelineQuery = useTicketTimelineQuery(ticketId);
|
||||
|
||||
if (timelineQuery.isLoading) {
|
||||
return <TicketTimelineSkeleton />;
|
||||
}
|
||||
|
||||
const history = timelineQuery.data?.history ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
|
||||
</div>
|
||||
<TimelineHeader />
|
||||
|
||||
{history.length ? (
|
||||
{timelineQuery.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6 text-center"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load the ticket timeline.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
disabled={timelineQuery.isFetching}
|
||||
onClick={() => timelineQuery.refetch()}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'size-4',
|
||||
timelineQuery.isFetching && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : history.length ? (
|
||||
<Timeline>
|
||||
{history.map((item, index) => (
|
||||
<TimelineItem
|
||||
|
||||
@@ -143,7 +143,7 @@ export function TicketOverviewCard({
|
||||
<div className="space-y-5">
|
||||
{uploadedOn || uploaderName || locationLabel ? (
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3 sm:divide-x">
|
||||
{uploadedOn ? (
|
||||
<div className="flex items-center gap-3 sm:pr-4">
|
||||
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
||||
@@ -183,7 +183,7 @@ export function TicketOverviewCard({
|
||||
{analysisVideoId ? (
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="default"
|
||||
onClick={() => setIsAnalysisOpen(true)}
|
||||
>
|
||||
<Play className="mr-2 size-4" />
|
||||
|
||||
@@ -31,9 +31,12 @@ function SummarySkeletonCard() {
|
||||
export function TicketOverviewHeaderSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-3 w-36 max-w-full" />
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Skeleton className="size-10 shrink-0 rounded-lg" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-3 w-36 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
||||
@@ -1,88 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketAssignmentSummary, TicketDetail } from '@/types';
|
||||
import type { TicketAssignmentSummary } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { AssignedContractorAction } from './actions/AssignedContractorAction';
|
||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
|
||||
interface TicketStatusActionsProps {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignment?: TicketAssignmentSummary;
|
||||
}
|
||||
|
||||
function OverdueLotAlert({
|
||||
lotId,
|
||||
dueAt,
|
||||
}: {
|
||||
lotId: number;
|
||||
dueAt: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">Lot {lotId} is overdue</p>
|
||||
<p className="mt-0.5 text-xs text-destructive/80">
|
||||
The due date expired on {formatDate(dueAt)}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTicketAction(
|
||||
ticket: TicketDetail,
|
||||
ticketId: string,
|
||||
assignment?: TicketAssignmentSummary,
|
||||
) {
|
||||
if (assignment && !assignment.is_complete) {
|
||||
if (assignment) {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.WORK}
|
||||
fallback={<NoTicketAction />}
|
||||
fallback={
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.READ}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<RequestExtensionAction
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
dueAt={assignment.due_at}
|
||||
canRequest={false}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<RequestExtensionAction
|
||||
ticket={ticket}
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
dueAt={assignment.due_at}
|
||||
canRequest={!assignment.is_complete}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<>
|
||||
{ticket.assignment_status === 'assigned' ? (
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
) : null}
|
||||
<ReviewExtensionRequestAction
|
||||
ticket={ticket}
|
||||
assignmentId={assignment.id}
|
||||
/>
|
||||
</>
|
||||
<ReviewExtensionRequestAction
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
canReview={!assignment.is_complete}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'unassigned') {
|
||||
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 />;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TicketStatusActions({
|
||||
ticket,
|
||||
ticketId,
|
||||
assignment,
|
||||
}: 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">
|
||||
{assignment?.is_overdue ? (
|
||||
<OverdueLotAlert lotId={assignment.id} dueAt={assignment.due_at} />
|
||||
) : null}
|
||||
{action}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||
|
||||
@@ -49,9 +48,9 @@ function TimelineEntrySkeleton({
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketClassContentSkeleton() {
|
||||
export function TicketTimelineSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4" aria-label="Loading defect class details">
|
||||
<div className="space-y-4" aria-label="Loading ticket timeline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-lg" />
|
||||
<Skeleton className="h-5 w-56 max-w-full" />
|
||||
@@ -69,29 +68,3 @@ export function TicketClassContentSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketClassActionsSkeleton() {
|
||||
return (
|
||||
<Card aria-label="Loading defect class actions">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-4 rounded-lg" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ListChecks } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import { TicketDetectionPreview } from './TicketDetectionPreview';
|
||||
|
||||
interface TicketUnassignedIssuesCardProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
}
|
||||
|
||||
export function TicketUnassignedIssuesCard({
|
||||
ticketId,
|
||||
videoId,
|
||||
}: TicketUnassignedIssuesCardProps) {
|
||||
const [detectionCount, setDetectionCount] = useState<number>();
|
||||
|
||||
if (!videoId) return null;
|
||||
|
||||
return (
|
||||
<Card className={'w-full'}>
|
||||
<CardHeader className="min-w-0 px-3 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListChecks className="size-5 text-primary" />
|
||||
Unassigned Issues
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Review detections awaiting assignment and discard invalid AI
|
||||
results.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={'default'}>{detectionCount ?? 0} Unassigned</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={'p-5'}>
|
||||
<div className={'min-w-0'}>
|
||||
<TicketDetectionPreview
|
||||
key={`${videoId}:unassigned`}
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
assignmentStatus={'unassigned'}
|
||||
onDetectionCountChange={setDetectionCount}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, History } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestStatus,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
const statusConfig: Record<
|
||||
TicketExtensionRequestStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
className:
|
||||
'border-amber-200 bg-amber-100 text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-300',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
className:
|
||||
'border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-300',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
className:
|
||||
'border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Cancelled',
|
||||
className: 'border-border bg-muted text-muted-foreground',
|
||||
},
|
||||
};
|
||||
|
||||
function HistoryItem({ request }: { request: TicketExtensionRequest }) {
|
||||
const status = statusConfig[request.status];
|
||||
|
||||
return (
|
||||
<article className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge variant="outline" className={cn('border', status.className)}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Requested {formatDate(request.requested_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Requested By</p>
|
||||
<p className="mt-0.5 text-sm font-medium">
|
||||
{request.requested_by?.name ?? 'Assigned worker'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg bg-muted/40 p-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Previous Due Date</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{formatDate(request.current_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="mt-5 size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Requested Due Date</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{formatDate(request.requested_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Reason</p>
|
||||
<p className="mt-1 text-sm leading-relaxed">{request.reason}</p>
|
||||
</div>
|
||||
|
||||
{request.resolved_at || request.resolved_by || request.decision_note ? (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<p className="text-xs font-semibold">Manager Response</p>
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
{request.resolved_by ? (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Resolved By</p>
|
||||
<p className="mt-0.5 font-medium">
|
||||
{request.resolved_by.name} · {request.resolved_by.role.name}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{request.resolved_at ? (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Resolved On</p>
|
||||
<p className="mt-0.5 font-medium">
|
||||
{formatDate(request.resolved_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{request.decision_note ? (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Manager Comments</p>
|
||||
<p className="mt-1 text-sm leading-relaxed">
|
||||
{request.decision_note}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExtensionRequestHistoryDialog({
|
||||
requests,
|
||||
}: {
|
||||
requests: TicketExtensionRequest[];
|
||||
}) {
|
||||
const sortedRequests = [...requests].sort(
|
||||
(left, right) =>
|
||||
new Date(right.requested_at).getTime() -
|
||||
new Date(left.requested_at).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
<History />
|
||||
View Request History ({requests.length})
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Extension Request History</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review every deadline extension request and manager decision for
|
||||
this ticket assignment.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-3">
|
||||
{sortedRequests.map((request) => (
|
||||
<HistoryItem key={request.id} request={request} />
|
||||
))}
|
||||
</DialogBody>
|
||||
<DialogFooter showCloseButton />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,22 +3,43 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) {
|
||||
interface OpenRepairReviewActionProps {
|
||||
ticketId: string;
|
||||
hasPendingReview: boolean;
|
||||
pendingReviewCount: number;
|
||||
}
|
||||
|
||||
export function OpenRepairReviewAction({
|
||||
ticketId,
|
||||
hasPendingReview,
|
||||
pendingReviewCount,
|
||||
}: OpenRepairReviewActionProps) {
|
||||
const router = useRouter();
|
||||
const count = Math.max(0, pendingReviewCount ?? 0);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class))
|
||||
disabled={!hasPendingReview}
|
||||
title={
|
||||
hasPendingReview
|
||||
? `${count} ${count === 1 ? 'repair is' : 'repairs are'} awaiting review`
|
||||
: 'No repairs are awaiting review'
|
||||
}
|
||||
onClick={() => router.push(ROUTES.TICKET_REVIEW(ticketId))}
|
||||
>
|
||||
<ClipboardCheck />
|
||||
Review Repairs
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 min-w-5 rounded-full px-1.5 font-semibold tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { Clock3 } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||
|
||||
export function RequestExtensionAction({
|
||||
ticket,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
dueAt,
|
||||
canRequest,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
dueAt: string | null;
|
||||
canRequest: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TicketActionCard icon={Clock3} title="Request Extension">
|
||||
<TicketActionCard
|
||||
icon={Clock3}
|
||||
title={canRequest ? 'Request Extension' : 'Extension Request History'}
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<TicketExtensionRequestPanel
|
||||
ticket={ticket}
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
dueAt={dueAt}
|
||||
canRequest={canRequest}
|
||||
/>
|
||||
</TicketActionCard>
|
||||
);
|
||||
|
||||
@@ -8,20 +8,19 @@ import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries';
|
||||
|
||||
const MAX_REASON_LENGTH = 300;
|
||||
|
||||
interface RequestExtensionFormProps {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
currentDueAt: string | null;
|
||||
}
|
||||
|
||||
export function RequestExtensionForm({
|
||||
ticket,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
currentDueAt,
|
||||
}: RequestExtensionFormProps) {
|
||||
@@ -42,9 +41,8 @@ export function RequestExtensionForm({
|
||||
const [requestedDueDate, setRequestedDueDate] = useState<Date>();
|
||||
const [reason, setReason] = useState('');
|
||||
const requestExtensionMutation = useRequestTicketExtensionMutation(
|
||||
ticket.id,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
ticket.defect_class,
|
||||
);
|
||||
const isPending = requestExtensionMutation.isPending;
|
||||
const isRequestedDateValid = Boolean(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
@@ -10,24 +9,37 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type {
|
||||
TicketDetail,
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestsResponse,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import {
|
||||
useReviewTicketExtensionMutation,
|
||||
useTicketExtensionRequestQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
MAX_EXTENSION_DECISION_NOTE_LENGTH,
|
||||
useExtensionRequestDecision,
|
||||
} from '../../../../extension-requests/hooks/useExtensionRequestDecision';
|
||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
|
||||
import {
|
||||
ExtensionRequestDetails,
|
||||
getLatestRequest,
|
||||
} from './TicketExtensionRequestPanel';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
const MAX_NOTE_LENGTH = 300;
|
||||
|
||||
function getPendingRequest(response: TicketExtensionRequestsResponse) {
|
||||
return response.items
|
||||
.filter((request) => request.status === 'pending')
|
||||
@@ -76,36 +88,35 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
|
||||
}
|
||||
|
||||
function ExtensionReviewForm({
|
||||
ticket,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
request,
|
||||
requests,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
request: TicketExtensionRequest;
|
||||
requests: TicketExtensionRequest[];
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
const [selectedAction, setSelectedAction] = useState<
|
||||
'approve' | 'reject' | null
|
||||
>(null);
|
||||
const reviewMutation = useReviewTicketExtensionMutation(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
ticket.defect_class,
|
||||
);
|
||||
const {
|
||||
note,
|
||||
setNote,
|
||||
noteError,
|
||||
selectedAction,
|
||||
isPending,
|
||||
isRejectConfirmationOpen,
|
||||
setIsRejectConfirmationOpen,
|
||||
submitDecision,
|
||||
requestRejection,
|
||||
} = useExtensionRequestDecision({ ticketId, assignmentId });
|
||||
const extensionDays = getExtensionDays(request);
|
||||
|
||||
const submitDecision = async (action: 'approve' | 'reject') => {
|
||||
setSelectedAction(action);
|
||||
try {
|
||||
await reviewMutation.mutateAsync({ action, note: note.trim() });
|
||||
} finally {
|
||||
setSelectedAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
|
||||
<TicketActionCard
|
||||
icon={CalendarClock}
|
||||
title="Extension Request Details"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Requester request={request} />
|
||||
|
||||
@@ -142,25 +153,39 @@ function ExtensionReviewForm({
|
||||
</section>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extension-manager-note">
|
||||
Add Manager Comments{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="extension-manager-note">Add Manager Comments</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for rejection and optional for approval.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="extension-manager-note"
|
||||
value={note}
|
||||
onChange={(event) =>
|
||||
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH))
|
||||
}
|
||||
onChange={(event) => {
|
||||
setNote(event.target.value);
|
||||
}}
|
||||
placeholder="Add a note explaining your decision..."
|
||||
disabled={reviewMutation.isPending}
|
||||
disabled={isPending}
|
||||
aria-invalid={Boolean(noteError)}
|
||||
aria-describedby={
|
||||
noteError ? 'extension-manager-note-error' : undefined
|
||||
}
|
||||
className="min-h-24 resize-none pb-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||
{note.length} / {MAX_NOTE_LENGTH}
|
||||
{note.length} / {MAX_EXTENSION_DECISION_NOTE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
{noteError ? (
|
||||
<p
|
||||
id="extension-manager-note-error"
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{noteError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -168,8 +193,8 @@ function ExtensionReviewForm({
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
|
||||
disabled={reviewMutation.isPending}
|
||||
onClick={() => submitDecision('reject')}
|
||||
disabled={isPending}
|
||||
onClick={requestRejection}
|
||||
>
|
||||
{selectedAction === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
@@ -180,8 +205,8 @@ function ExtensionReviewForm({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={reviewMutation.isPending}
|
||||
onClick={() => submitDecision('approve')}
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
>
|
||||
{selectedAction === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
@@ -191,20 +216,49 @@ function ExtensionReviewForm({
|
||||
Approve Extension
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ExtensionRequestHistoryDialog requests={requests} />
|
||||
|
||||
<AlertDialog
|
||||
open={isRejectConfirmationOpen}
|
||||
onOpenChange={setIsRejectConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to reject this extension request? This
|
||||
decision cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
>
|
||||
Yes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewExtensionRequestAction({
|
||||
ticket,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
canReview,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
canReview: boolean;
|
||||
}) {
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
@@ -214,7 +268,11 @@ export function ReviewExtensionRequestAction({
|
||||
|
||||
if (extensionRequestQuery.isLoading) {
|
||||
return (
|
||||
<TicketActionCard icon={Clock3} title="Extension Request">
|
||||
<TicketActionCard
|
||||
icon={Clock3}
|
||||
title="Extension Request"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
<span className="text-sm">Checking for requests...</span>
|
||||
@@ -227,13 +285,32 @@ export function ReviewExtensionRequestAction({
|
||||
? getPendingRequest(extensionRequestQuery.data)
|
||||
: null;
|
||||
|
||||
if (!pendingRequest) return null;
|
||||
const requests = extensionRequestQuery.data?.items ?? [];
|
||||
|
||||
if (!pendingRequest || !canReview) {
|
||||
const latestRequest = extensionRequestQuery.data
|
||||
? getLatestRequest(extensionRequestQuery.data)
|
||||
: null;
|
||||
|
||||
if (!latestRequest) return null;
|
||||
|
||||
return (
|
||||
<TicketActionCard
|
||||
icon={CalendarClock}
|
||||
title="Extension Request"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<ExtensionRequestDetails request={latestRequest} requests={requests} />
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtensionReviewForm
|
||||
ticket={ticket}
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
request={pendingRequest}
|
||||
requests={requests}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -9,6 +10,7 @@ interface TicketActionCardProps {
|
||||
icon: LucideIcon;
|
||||
iconClassName?: string;
|
||||
title: string;
|
||||
contextLabel?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -16,15 +18,23 @@ export function TicketActionCard({
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
title,
|
||||
contextLabel,
|
||||
children,
|
||||
}: TicketActionCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Icon className={cn('text-primary', iconClassName)} />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-base">
|
||||
<Icon className={cn('shrink-0 text-primary', iconClassName)} />
|
||||
<span className="truncate">{title}</span>
|
||||
</CardTitle>
|
||||
{contextLabel ? (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
History,
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -14,13 +15,13 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
TicketDetail,
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestsResponse,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
|
||||
import { RequestExtensionForm } from './RequestExtensionForm';
|
||||
|
||||
interface DetailRowProps {
|
||||
@@ -45,21 +46,21 @@ function DetailRow({ label, value, children, className }: DetailRowProps) {
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
title: 'Request Pending',
|
||||
description: 'Your extension request is waiting for manager approval.',
|
||||
description: 'This extension request is waiting for manager approval.',
|
||||
icon: Clock3,
|
||||
className:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300',
|
||||
},
|
||||
approved: {
|
||||
title: 'Request Approved',
|
||||
description: 'Your extension request has been approved by the manager.',
|
||||
description: 'This extension request was approved by the manager.',
|
||||
icon: CheckCircle2,
|
||||
className:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300',
|
||||
},
|
||||
rejected: {
|
||||
title: 'Request Rejected',
|
||||
description: 'Your extension request has been rejected by the manager.',
|
||||
description: 'This extension request was rejected by the manager.',
|
||||
icon: AlertCircle,
|
||||
className:
|
||||
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300',
|
||||
@@ -80,7 +81,7 @@ const statusConfig = {
|
||||
}
|
||||
>;
|
||||
|
||||
function getLatestRequest(response: TicketExtensionRequestsResponse) {
|
||||
export function getLatestRequest(response: TicketExtensionRequestsResponse) {
|
||||
return response.items.reduce<TicketExtensionRequest | null>(
|
||||
(latest, item) => {
|
||||
if (!latest) return item;
|
||||
@@ -99,10 +100,14 @@ function getExtensionDays(request: TicketExtensionRequest) {
|
||||
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
function ExtensionRequestDetails({
|
||||
export function ExtensionRequestDetails({
|
||||
request,
|
||||
requests,
|
||||
onRequestAnother,
|
||||
}: {
|
||||
request: TicketExtensionRequest;
|
||||
requests: TicketExtensionRequest[];
|
||||
onRequestAnother?: () => void;
|
||||
}) {
|
||||
const config = statusConfig[request.status];
|
||||
const StatusIcon = config.icon;
|
||||
@@ -125,6 +130,10 @@ function ExtensionRequestDetails({
|
||||
label="Requested On"
|
||||
value={formatDate(request.requested_at)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Requested By"
|
||||
value={request.requested_by?.name ?? 'Assigned worker'}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Requested Due Date"
|
||||
value={formatDate(request.requested_due_at)}
|
||||
@@ -161,6 +170,12 @@ function ExtensionRequestDetails({
|
||||
value={formatDate(request.requested_due_at)}
|
||||
/>
|
||||
) : null}
|
||||
{request.decision_note ? (
|
||||
<DetailRow
|
||||
label="Manager Comments"
|
||||
value={request.decision_note}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
@@ -177,25 +192,33 @@ function ExtensionRequestDetails({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
<History />
|
||||
View Approval History
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<ExtensionRequestHistoryDialog requests={requests} />
|
||||
{request.status !== 'pending' && onRequestAnother ? (
|
||||
<Button type="button" className="w-full" onClick={onRequestAnother}>
|
||||
<Plus />
|
||||
Request Another Extension
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketExtensionRequestPanel({
|
||||
ticket,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
dueAt,
|
||||
canRequest,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
dueAt: string | null;
|
||||
canRequest: boolean;
|
||||
}) {
|
||||
const [isRequestFormOpen, setIsRequestFormOpen] = useState(false);
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
@@ -232,14 +255,50 @@ export function TicketExtensionRequestPanel({
|
||||
: null;
|
||||
|
||||
if (!latestRequest) {
|
||||
if (!canRequest) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No extension request history is available for this ticket assignment.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RequestExtensionForm
|
||||
ticket={ticket}
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
currentDueAt={dueAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ExtensionRequestDetails request={latestRequest} />;
|
||||
if (isRequestFormOpen && latestRequest.status !== 'pending') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsRequestFormOpen(false)}
|
||||
>
|
||||
Back to latest request
|
||||
</Button>
|
||||
<RequestExtensionForm
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
currentDueAt={dueAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtensionRequestDetails
|
||||
request={latestRequest}
|
||||
requests={extensionRequestQuery.data?.items ?? []}
|
||||
onRequestAnother={
|
||||
canRequest ? () => setIsRequestFormOpen(true) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,26 +5,23 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { TicketDetailHeader } from './components/TicketDetailHeader';
|
||||
import {
|
||||
TicketOverviewCardSkeleton,
|
||||
TicketOverviewHeaderSkeleton,
|
||||
} from './components/TicketOverviewSkeleton';
|
||||
import {
|
||||
TicketClassActionsSkeleton,
|
||||
TicketClassContentSkeleton,
|
||||
} from './components/TicketClassDetailSkeleton';
|
||||
|
||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
|
||||
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||
import { TicketUnassignedIssuesCard } from './components/TicketUnassignedIssuesCard';
|
||||
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||
import {
|
||||
useTicketAssignmentsQuery,
|
||||
useTicketClassDetailQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../hooks/useTicketQueries';
|
||||
|
||||
@@ -32,8 +29,18 @@ export default function TicketDetailPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const defectClass = searchParams.get('defect_class') ?? undefined;
|
||||
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
|
||||
const listParams = new URLSearchParams();
|
||||
|
||||
for (const key of ['page', 'limit', 'segment']) {
|
||||
const value = searchParams.get(key);
|
||||
if (value) listParams.set(key, value);
|
||||
}
|
||||
|
||||
const listSearch = listParams.toString();
|
||||
const ticketListHref = listSearch
|
||||
? `${ROUTES.TICKET}?${listSearch}`
|
||||
: ROUTES.TICKET;
|
||||
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const overview = overviewQuery.data;
|
||||
@@ -41,18 +48,15 @@ export default function TicketDetailPage() {
|
||||
ticketId,
|
||||
overview?.assignments,
|
||||
);
|
||||
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
|
||||
const classDetail = classDetailQuery.data;
|
||||
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 assignments = (assignmentsQuery.data?.items ?? []).filter(
|
||||
(assignment) => assignment.item_count > 0,
|
||||
);
|
||||
const activeAssignment =
|
||||
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
|
||||
assignments[0];
|
||||
const activeAssignmentId = activeAssignment?.id;
|
||||
|
||||
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
|
||||
useTicketDetailEvents(ticketId, Boolean(ticketId));
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
@@ -61,7 +65,7 @@ export default function TicketDetailPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push('/ticket')}
|
||||
onClick={() => router.push(ticketListHref)}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back to Tickets
|
||||
@@ -74,7 +78,7 @@ export default function TicketDetailPage() {
|
||||
<main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<div className="xl:shrink-0">
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} classDetail={classDetail} />
|
||||
<TicketDetailHeader ticket={overview} backHref={ticketListHref} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
@@ -90,27 +94,27 @@ export default function TicketDetailPage() {
|
||||
{overview ? (
|
||||
<TicketAssignmentsCard
|
||||
ticketId={ticketId}
|
||||
videoId={overview.video_id ?? overview.video?.id}
|
||||
initialAssignments={overview.assignments}
|
||||
selectedAssignmentId={activeAssignmentId}
|
||||
onAssignmentChange={setSelectedAssignmentId}
|
||||
/>
|
||||
) : null}
|
||||
<TicketDefectClassTabs
|
||||
ticketId={ticketId}
|
||||
ticket={overview}
|
||||
assignmentId={activeAssignmentId}
|
||||
/>
|
||||
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<TicketHistoryCard ticket={classDetail} />
|
||||
{overview ? (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<TicketUnassignedIssuesCard
|
||||
ticketId={ticketId}
|
||||
videoId={overview.video_id ?? overview.video?.id}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
) : null}
|
||||
<TicketHistoryCard ticketId={ticketId} />
|
||||
</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">
|
||||
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
{activeAssignment ? (
|
||||
<TicketStatusActions
|
||||
ticket={classDetail}
|
||||
ticketId={ticketId}
|
||||
assignment={activeAssignment}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { RepairProofGallery } from '../../../components/repair/RepairProofGallery';
|
||||
import { RepairProofGallery } from '../../components/repair/RepairProofGallery';
|
||||
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
|
||||
|
||||
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
|
||||
@@ -176,13 +176,9 @@ export function IssueReviewAction({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
className={cn(
|
||||
decision === 'approve' &&
|
||||
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
|
||||
)}
|
||||
>
|
||||
{isPending && decision === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
@@ -193,13 +189,9 @@ export function IssueReviewAction({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
className={cn(
|
||||
decision === 'reject' &&
|
||||
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
|
||||
)}
|
||||
>
|
||||
{isPending && decision === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
@@ -17,7 +17,6 @@ const PROOF_STATUS_FILTERS: Array<{
|
||||
|
||||
interface IssueReviewHeaderProps {
|
||||
ticketLabel: string;
|
||||
defectLabel: string;
|
||||
currentIndex: number;
|
||||
total: number;
|
||||
proofStatus: ProofStatusFilter;
|
||||
@@ -29,7 +28,6 @@ interface IssueReviewHeaderProps {
|
||||
|
||||
export function IssueReviewHeader({
|
||||
ticketLabel,
|
||||
defectLabel,
|
||||
currentIndex,
|
||||
total,
|
||||
proofStatus,
|
||||
@@ -54,7 +52,7 @@ export function IssueReviewHeader({
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight">
|
||||
{defectLabel} Review
|
||||
Repair Review
|
||||
</h1>
|
||||
<Badge variant="secondary">{ticketLabel}</Badge>
|
||||
</div>
|
||||
@@ -79,8 +79,6 @@ export function IssueReviewQueue({
|
||||
onLoadMore,
|
||||
}: IssueReviewQueueProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const defectLabel = detections[0]?.detection.display_name ?? 'Detected';
|
||||
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || !hasNextPage || isFetchingNextPage) return;
|
||||
@@ -104,7 +102,7 @@ export function IssueReviewQueue({
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ListChecks className="size-5 text-primary" />
|
||||
{defectLabel} Issues
|
||||
Repair Issues
|
||||
<Badge variant="secondary" className="ml-auto rounded-full">
|
||||
{totalCount}
|
||||
</Badge>
|
||||
@@ -115,6 +113,7 @@ export function IssueReviewQueue({
|
||||
<div className="space-y-2 p-1">
|
||||
{detections.map((detection, index) => {
|
||||
const issueId = getIssueId(detection);
|
||||
const defectLabel = detection.detection.display_name;
|
||||
const status = getIssueReviewStatus(detection);
|
||||
const config = STATUS_CONFIG[status];
|
||||
const StatusIcon = config.icon;
|
||||
@@ -18,9 +18,9 @@ import type { ReviewDetectionRepairProofPayload } from '@/types';
|
||||
|
||||
import {
|
||||
useReviewDetectionRepairProofMutation,
|
||||
useTicketClassReviewDetectionsQuery,
|
||||
useTicketReviewDetectionsQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { IssueEvidencePanel } from './components/IssueEvidencePanel';
|
||||
import { IssueReviewAction } from './components/IssueReviewAction';
|
||||
import { IssueReviewHeader } from './components/IssueReviewHeader';
|
||||
@@ -39,14 +39,11 @@ const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
|
||||
'rejected',
|
||||
];
|
||||
|
||||
export default function TicketClassReviewPage() {
|
||||
export default function TicketReviewPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { ticketId, defectClass } = useParams() as {
|
||||
ticketId: string;
|
||||
defectClass: string;
|
||||
};
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const proofStatusParam = searchParams.get('proof_status');
|
||||
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
|
||||
proofStatusParam as ProofStatusFilter,
|
||||
@@ -57,9 +54,8 @@ export default function TicketClassReviewPage() {
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const ticket = overviewQuery.data;
|
||||
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
|
||||
const detectionsQuery = useTicketClassReviewDetectionsQuery(
|
||||
const detectionsQuery = useTicketReviewDetectionsQuery(
|
||||
videoId,
|
||||
defectClass,
|
||||
proofStatus === 'all' ? 'submitted' : proofStatus,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data?.pages[0];
|
||||
@@ -72,7 +68,6 @@ export default function TicketClassReviewPage() {
|
||||
const reviewMutation = useReviewDetectionRepairProofMutation({
|
||||
ticketId,
|
||||
videoId,
|
||||
defectClass,
|
||||
});
|
||||
|
||||
const selectIssue = useCallback(
|
||||
@@ -171,8 +166,7 @@ export default function TicketClassReviewPage() {
|
||||
}, [detectionsQuery]);
|
||||
|
||||
const backToTicket = () => {
|
||||
const params = new URLSearchParams({ defect_class: defectClass });
|
||||
router.push(`${ROUTES.TICKET_DETAIL(ticketId)}?${params.toString()}`);
|
||||
router.push(ROUTES.TICKET_DETAIL(ticketId));
|
||||
};
|
||||
|
||||
if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) {
|
||||
@@ -206,7 +200,7 @@ export default function TicketClassReviewPage() {
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">Failed to load class detections.</p>
|
||||
<p className="font-semibold">Failed to load detections.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The review screen uses the detection API for its queue and
|
||||
evidence.
|
||||
@@ -233,7 +227,6 @@ export default function TicketClassReviewPage() {
|
||||
<div className="space-y-5">
|
||||
<IssueReviewHeader
|
||||
ticketLabel={ticket.ticket_name || ticket.id}
|
||||
defectLabel={defectClass}
|
||||
currentIndex={0}
|
||||
total={0}
|
||||
proofStatus={proofStatus}
|
||||
@@ -248,7 +241,7 @@ export default function TicketClassReviewPage() {
|
||||
<div>
|
||||
<p className="font-semibold">No detections found.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
There are no {defectClass} issues available for review.
|
||||
There are no issues available for review.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -262,7 +255,6 @@ export default function TicketClassReviewPage() {
|
||||
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<IssueReviewHeader
|
||||
ticketLabel={ticket.ticket_name || ticket.id}
|
||||
defectLabel={defectLabel}
|
||||
currentIndex={activeIndex}
|
||||
total={totalCount}
|
||||
proofStatus={proofStatus}
|
||||
@@ -2,11 +2,48 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { CalendarClock, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { TicketListItem } from '@/types';
|
||||
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
function TicketAttention({ ticket }: { ticket: TicketListItem }) {
|
||||
const summary = ticket.attention_summary;
|
||||
const reviewCount = summary?.pending_repair_review_count ?? 0;
|
||||
const extensionCount = summary?.pending_extension_request_count ?? 0;
|
||||
const showReview = reviewCount > 0;
|
||||
const showExtension = extensionCount > 0;
|
||||
|
||||
if (!showReview && !showExtension) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{showReview ? (
|
||||
<Badge
|
||||
className="gap-1.5 border-0 bg-orange-100 text-orange-700 shadow-none dark:bg-orange-950/60 dark:text-orange-300"
|
||||
title={`${reviewCount} pending repair ${reviewCount === 1 ? 'review' : 'reviews'}`}
|
||||
>
|
||||
<ClipboardCheck aria-hidden="true" />
|
||||
Review
|
||||
<span className="font-semibold tabular-nums">{reviewCount}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{showExtension ? (
|
||||
<Badge
|
||||
className="gap-1.5 border-0 bg-blue-100 text-blue-700 shadow-none dark:bg-blue-950/60 dark:text-blue-300"
|
||||
title={`${extensionCount} pending extension ${extensionCount === 1 ? 'request' : 'requests'}`}
|
||||
>
|
||||
<CalendarClock aria-hidden="true" />
|
||||
Extension
|
||||
<span className="font-semibold tabular-nums">+{extensionCount}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
@@ -29,6 +66,24 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||
header: 'Detections',
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'attention',
|
||||
header: 'Attention',
|
||||
size: 210,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <TicketAttention ticket={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_closed',
|
||||
header: 'Status',
|
||||
size: 100,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.is_closed ? 'secondary' : 'default'}>
|
||||
{row.original.is_closed ? 'Closed' : 'Open'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'created_by',
|
||||
header: 'Uploaded By',
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { FilterX } from 'lucide-react';
|
||||
|
||||
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface TicketFiltersProps {
|
||||
segmentId: string;
|
||||
onSegmentChange: (segmentId: string) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function TicketFilters({
|
||||
segmentId,
|
||||
onSegmentChange,
|
||||
onClear,
|
||||
}: TicketFiltersProps) {
|
||||
return (
|
||||
<div className="w-full sm:w-64">
|
||||
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="w-full sm:w-64">
|
||||
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!segmentId}
|
||||
onClick={onClear}
|
||||
>
|
||||
<FilterX />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
138
src/app/(modules)/ticket/components/TicketSummaryCards.tsx
Normal file
138
src/app/(modules)/ticket/components/TicketSummaryCards.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleCheck,
|
||||
ClipboardCheck,
|
||||
ListChecks,
|
||||
Ticket,
|
||||
TriangleAlert,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { TicketSummaryResponse } from '@/types';
|
||||
|
||||
interface TicketSummaryCardsProps {
|
||||
summary?: TicketSummaryResponse;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
interface SummaryMetric {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: LucideIcon;
|
||||
iconClassName: string;
|
||||
}
|
||||
|
||||
function SummaryCard({ metric }: { metric: SummaryMetric }) {
|
||||
const Icon = metric.icon;
|
||||
|
||||
return (
|
||||
<Card size="sm" className="py-4">
|
||||
<CardContent className="flex items-start justify-between gap-3 px-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{metric.value.toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
||||
{metric.label}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`rounded-lg p-2 ${metric.iconClassName}`}>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketSummarySkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5"
|
||||
aria-label="Loading ticket summary"
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Card key={index} size="sm" className="py-4">
|
||||
<CardContent className="flex items-start justify-between gap-3 px-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-14" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="size-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketSummaryCards({
|
||||
summary,
|
||||
isLoading,
|
||||
isError,
|
||||
}: TicketSummaryCardsProps) {
|
||||
if (isLoading) return <TicketSummarySkeleton />;
|
||||
|
||||
if (isError || !summary) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
<TriangleAlert className="size-4" aria-hidden="true" />
|
||||
Ticket summary is temporarily unavailable.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics: SummaryMetric[] = [
|
||||
{
|
||||
label: 'Total Tickets',
|
||||
value: summary.total_tickets,
|
||||
icon: Ticket,
|
||||
iconClassName:
|
||||
'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
},
|
||||
{
|
||||
label: 'In Progress',
|
||||
value: summary.in_progress_tickets,
|
||||
icon: ListChecks,
|
||||
iconClassName:
|
||||
'bg-indigo-100 text-indigo-700 dark:bg-indigo-950/60 dark:text-indigo-300',
|
||||
},
|
||||
{
|
||||
label: 'Repair Reviews',
|
||||
value: summary.pending_actions.repair_reviews,
|
||||
icon: ClipboardCheck,
|
||||
iconClassName:
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-950/60 dark:text-orange-300',
|
||||
},
|
||||
{
|
||||
label: 'Extension Requests',
|
||||
value: summary.pending_actions.extension_requests,
|
||||
icon: CalendarClock,
|
||||
iconClassName:
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: summary.completed_tickets,
|
||||
icon: CircleCheck,
|
||||
iconClassName:
|
||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Ticket summary"
|
||||
className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5"
|
||||
>
|
||||
{metrics.map((metric) => (
|
||||
<SummaryCard key={metric.label} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -99,10 +99,17 @@ function buildTicketListItem(
|
||||
chainage_name: event.chainage_name ?? null,
|
||||
default_defect_class: event.default_defect_class ?? null,
|
||||
detection_count: event.detection_count,
|
||||
is_closed: event.is_closed ?? false,
|
||||
created_by_name: event.created_by_name ?? null,
|
||||
created_by_email: event.created_by_email ?? null,
|
||||
created_at: event.created_at ?? event.updated_at,
|
||||
updated_at: event.updated_at,
|
||||
attention_summary: event.attention_summary ?? {
|
||||
has_pending_extension_request: false,
|
||||
pending_extension_request_count: 0,
|
||||
has_pending_repair_review: false,
|
||||
pending_repair_review_count: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,10 +125,12 @@ function mergeTicketListItem(
|
||||
default_defect_class:
|
||||
event.default_defect_class ?? current.default_defect_class,
|
||||
detection_count: event.detection_count ?? current.detection_count,
|
||||
is_closed: event.is_closed ?? current.is_closed,
|
||||
created_by_name: event.created_by_name ?? current.created_by_name,
|
||||
created_by_email: event.created_by_email ?? current.created_by_email,
|
||||
created_at: event.created_at ?? current.created_at,
|
||||
updated_at: event.updated_at ?? current.updated_at,
|
||||
attention_summary: event.attention_summary ?? current.attention_summary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +141,8 @@ export function useTenantTicketTableEvents(enabled = true) {
|
||||
const events = useMemo(
|
||||
() => ({
|
||||
ticket_status: (event: TicketTableStatusEvent) => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
|
||||
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey: ticketKeys.lists() })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||
@@ -18,15 +18,9 @@ function isHeartbeatEvent(event: { type?: string } | null | undefined) {
|
||||
|
||||
export function useTicketDetailEvents(
|
||||
ticketId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
enabled = true,
|
||||
): void {
|
||||
const queryClient = useQueryClient();
|
||||
const defectClassRef = useRef(defectClass);
|
||||
|
||||
useEffect(() => {
|
||||
defectClassRef.current = defectClass;
|
||||
}, [defectClass]);
|
||||
|
||||
const refetchTicket = useCallback(() => {
|
||||
if (!ticketId) return;
|
||||
@@ -35,13 +29,8 @@ export function useTicketDetailEvents(
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
if (defectClassRef.current) {
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
}, [queryClient, ticketId]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
@@ -13,22 +14,26 @@ import { detectionService, ticketService, videoService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
CloseTicketPayload,
|
||||
DetectionCoordinatesParams,
|
||||
DiscardDetectionPayload,
|
||||
RequestTicketExtensionPayload,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
ReviewTicketExtensionPayload,
|
||||
TicketDetail,
|
||||
TicketAssignmentSummary,
|
||||
TicketAssignmentDetectionsParams,
|
||||
TicketListParams,
|
||||
DetectionProofStatus,
|
||||
VideoDetectionsParams,
|
||||
} from '@/types';
|
||||
|
||||
import { ticketKeys } from '../queries/ticketKeys';
|
||||
import { extensionRequestKeys } from '../../extension-requests/queries/extensionRequestKeys';
|
||||
|
||||
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
interface TicketDetectionsQueryOptions {
|
||||
enabled?: boolean;
|
||||
keepPreviousPage?: boolean;
|
||||
}
|
||||
|
||||
interface UseTicketsQueryParams {
|
||||
skip: number;
|
||||
limit: number;
|
||||
@@ -62,6 +67,20 @@ export function useTicketsQuery(params: UseTicketsQueryParams) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketSummaryQuery(chainageId?: string) {
|
||||
const summaryParams = useMemo(
|
||||
() => ({ chainage_id: chainageId || undefined }),
|
||||
[chainageId],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.summary(summaryParams),
|
||||
queryFn: () => ticketService.getTicketSummary(summaryParams),
|
||||
staleTime: TICKET_TABLE_REFRESH_MS,
|
||||
refetchInterval: TICKET_TABLE_REFRESH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketOverviewQuery(ticketId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.overview(ticketId ?? ''),
|
||||
@@ -70,25 +89,10 @@ export function useTicketOverviewQuery(ticketId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketClassDetailQuery(
|
||||
ticketId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
) {
|
||||
export function useTicketTimelineQuery(ticketId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''),
|
||||
queryFn: () =>
|
||||
ticketService.getTicketClassDetail(
|
||||
ticketId as string,
|
||||
defectClass as string,
|
||||
),
|
||||
enabled: Boolean(ticketId && defectClass),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
|
||||
queryKey: ticketKeys.timeline(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketTimeline(ticketId as string),
|
||||
enabled: Boolean(ticketId),
|
||||
});
|
||||
}
|
||||
@@ -115,12 +119,15 @@ export function useTicketAssignmentsQuery(
|
||||
export function useTicketDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoDetectionsParams,
|
||||
options: TicketDetectionsQueryOptions = {},
|
||||
) {
|
||||
const { enabled = true, keepPreviousPage = false } = options;
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 1,
|
||||
assignment_id: params.assignment_id,
|
||||
assignment_status: params.assignment_status,
|
||||
class_name: params.class_name,
|
||||
min_confidence: params.min_confidence,
|
||||
sort: params.sort ?? 'timestamp_asc',
|
||||
@@ -128,6 +135,7 @@ export function useTicketDetectionsQuery(
|
||||
}),
|
||||
[
|
||||
params.assignment_id,
|
||||
params.assignment_status,
|
||||
params.class_name,
|
||||
params.limit,
|
||||
params.min_confidence,
|
||||
@@ -141,44 +149,9 @@ export function useTicketDetectionsQuery(
|
||||
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
|
||||
queryFn: () =>
|
||||
videoService.getVideoDetections(videoId as string, queryParams),
|
||||
enabled: Boolean(videoId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetectionCoordinatesQuery(
|
||||
videoId: string | undefined,
|
||||
params: DetectionCoordinatesParams,
|
||||
) {
|
||||
const queryParams = useMemo<DetectionCoordinatesParams>(
|
||||
() => ({
|
||||
limit: params.limit ?? 50,
|
||||
class_name: params.class_name,
|
||||
assignment_status: params.assignment_status,
|
||||
search: params.search,
|
||||
}),
|
||||
[params.assignment_status, params.class_name, params.limit, params.search],
|
||||
);
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ticketKeys.detectionCoordinates(videoId ?? '', queryParams),
|
||||
queryFn: ({ pageParam }) =>
|
||||
detectionService.getCoordinates(videoId as string, {
|
||||
...queryParams,
|
||||
skip: pageParam,
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.items.length === 0) return undefined;
|
||||
|
||||
const loadedCount = allPages.reduce(
|
||||
(total, page) => total + page.items.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return loadedCount < lastPage.total ? loadedCount : undefined;
|
||||
},
|
||||
enabled: Boolean(videoId),
|
||||
staleTime: 60 * 1000,
|
||||
enabled: Boolean(enabled && videoId),
|
||||
placeholderData: keepPreviousPage ? keepPreviousData : undefined,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,22 +162,43 @@ export function useTicketClassDetectionsQuery(
|
||||
return useTicketDetectionsQuery(videoId, params);
|
||||
}
|
||||
|
||||
export function useTicketClassReviewDetectionsQuery(
|
||||
export function useTicketAssignmentDetectionsQuery(
|
||||
ticketId: string | undefined,
|
||||
params: TicketAssignmentDetectionsParams,
|
||||
enabled = true,
|
||||
) {
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
assignment_status: params.assignment_status ?? 'unassigned',
|
||||
class_name: params.class_name,
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 500,
|
||||
}),
|
||||
[params.assignment_status, params.class_name, params.limit, params.skip],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.assignmentDetections(ticketId ?? '', queryParams),
|
||||
queryFn: () =>
|
||||
ticketService.getTicketAssignmentDetections(
|
||||
ticketId as string,
|
||||
queryParams,
|
||||
),
|
||||
enabled: Boolean(enabled && ticketId),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketReviewDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
proofStatus?: DetectionProofStatus,
|
||||
) {
|
||||
const pageSize = 20;
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ticketKeys.classReviewDetections(
|
||||
videoId ?? '',
|
||||
defectClass ?? '',
|
||||
proofStatus,
|
||||
),
|
||||
queryKey: ticketKeys.reviewDetections(videoId ?? '', proofStatus),
|
||||
queryFn: ({ pageParam }) =>
|
||||
videoService.getVideoDetections(videoId as string, {
|
||||
class_name: defectClass,
|
||||
proof_status: proofStatus,
|
||||
sort: 'timestamp_asc',
|
||||
skip: pageParam,
|
||||
@@ -215,14 +209,13 @@ export function useTicketClassReviewDetectionsQuery(
|
||||
const nextSkip = lastPage.skip + lastPage.items.length;
|
||||
return nextSkip < lastPage.total ? nextSkip : undefined;
|
||||
},
|
||||
enabled: Boolean(videoId && defectClass),
|
||||
enabled: Boolean(videoId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiscardDetectionMutation(
|
||||
ticketId: string,
|
||||
videoId: string | undefined,
|
||||
defectClass?: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -246,17 +239,11 @@ export function useDiscardDetectionMutation(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
...(defectClass
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
}),
|
||||
@@ -269,11 +256,9 @@ export function useDiscardDetectionMutation(
|
||||
export function useReviewDetectionRepairProofMutation({
|
||||
ticketId,
|
||||
videoId,
|
||||
defectClass,
|
||||
}: {
|
||||
ticketId: string;
|
||||
videoId: string | undefined;
|
||||
defectClass: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -298,16 +283,14 @@ export function useReviewDetectionRepairProofMutation({
|
||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||
})
|
||||
: Promise.resolve(),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
|
||||
]);
|
||||
},
|
||||
onError: () => toast.error('Failed to review repair proof'),
|
||||
@@ -337,20 +320,7 @@ 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) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
@@ -362,17 +332,17 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
...(videoId
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.detectionCoordinateLists(videoId),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: () => toast.error('Failed to assign ticket'),
|
||||
@@ -382,7 +352,6 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
|
||||
export function useRequestTicketExtensionMutation(
|
||||
ticketId: string,
|
||||
assignmentId: number | undefined,
|
||||
defectClass: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -403,59 +372,39 @@ export function useRequestTicketExtensionMutation(
|
||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
queryKey: extensionRequestKeys.lists(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
|
||||
},
|
||||
onError: () => toast.error('Failed to request an extension'),
|
||||
});
|
||||
}
|
||||
export function useReviewTicketExtensionMutation(
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
defectClass: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: ReviewTicketExtensionPayload) =>
|
||||
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
|
||||
onSuccess: (_response, payload) => {
|
||||
toast.success(
|
||||
payload.action === 'approve'
|
||||
? 'Extension request approved'
|
||||
: 'Extension request rejected',
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to review extension request'),
|
||||
});
|
||||
}
|
||||
export function useCloseTicketMutation(ticketId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: CloseTicketPayload) =>
|
||||
ticketService.closeTicket(ticketId, payload),
|
||||
onSuccess: (ticket, payload) => {
|
||||
onSuccess: () => {
|
||||
toast.success('Ticket closed');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
|
||||
},
|
||||
onError: () => toast.error('Failed to close ticket'),
|
||||
});
|
||||
|
||||
@@ -1,22 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Ticket } from 'lucide-react';
|
||||
import type { TicketListItem } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { TicketFilters } from './components/TicketFilters';
|
||||
import { TicketSummaryCards } from './components/TicketSummaryCards';
|
||||
import { TicketTable } from './components/TicketTable';
|
||||
import { useTicketColumns } from './components/TicketColumns';
|
||||
import { useTenantTicketTableEvents } from './hooks/useTenantTicketTableEvents';
|
||||
import { useTicketsQuery } from './hooks/useTicketQueries';
|
||||
import {
|
||||
useTicketsQuery,
|
||||
useTicketSummaryQuery,
|
||||
} from './hooks/useTicketQueries';
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const PAGE_SIZE_OPTIONS = new Set([10, 20, 40, 50, 100]);
|
||||
|
||||
function parsePage(value: string | null) {
|
||||
const page = Number(value);
|
||||
return Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE;
|
||||
}
|
||||
|
||||
function parseLimit(value: string | null) {
|
||||
const limit = Number(value);
|
||||
return PAGE_SIZE_OPTIONS.has(limit) ? limit : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
export default function TicketPage() {
|
||||
const router = useRouter();
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit, setLimit] = useState(10);
|
||||
const [segmentId, setSegmentId] = useState('');
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParamsString = searchParams.toString();
|
||||
const latestSearchParamsRef = useRef(searchParamsString);
|
||||
|
||||
useEffect(() => {
|
||||
latestSearchParamsRef.current = searchParamsString;
|
||||
}, [searchParamsString]);
|
||||
|
||||
const page = parsePage(searchParams.get('page'));
|
||||
const limit = parseLimit(searchParams.get('limit'));
|
||||
const segmentId = searchParams.get('segment') ?? '';
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const replaceListParams = useCallback(
|
||||
(update: (params: URLSearchParams) => void) => {
|
||||
const nextParams = new URLSearchParams(latestSearchParamsRef.current);
|
||||
update(nextParams);
|
||||
const nextSearch = nextParams.toString();
|
||||
latestSearchParamsRef.current = nextSearch;
|
||||
router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[pathname, router],
|
||||
);
|
||||
|
||||
useTenantTicketTableEvents();
|
||||
|
||||
@@ -25,25 +67,62 @@ export default function TicketPage() {
|
||||
limit,
|
||||
chainageId: segmentId,
|
||||
});
|
||||
const summaryQuery = useTicketSummaryQuery(segmentId);
|
||||
|
||||
const columns = useTicketColumns();
|
||||
const tickets = ticketsQuery.data?.items ?? [];
|
||||
const total = ticketsQuery.data?.total ?? 0;
|
||||
|
||||
const handleSegmentChange = useCallback((nextSegmentId: string) => {
|
||||
setSegmentId(nextSegmentId);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
const handleSegmentChange = useCallback(
|
||||
(nextSegmentId: string) => {
|
||||
replaceListParams((params) => {
|
||||
params.delete('page');
|
||||
if (nextSegmentId) {
|
||||
params.set('segment', nextSegmentId);
|
||||
} else {
|
||||
params.delete('segment');
|
||||
}
|
||||
});
|
||||
},
|
||||
[replaceListParams],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(nextSkip: number) => {
|
||||
const nextPage = Math.floor(nextSkip / limit) + 1;
|
||||
replaceListParams((params) => {
|
||||
if (nextPage === DEFAULT_PAGE) {
|
||||
params.delete('page');
|
||||
} else {
|
||||
params.set('page', String(nextPage));
|
||||
}
|
||||
});
|
||||
},
|
||||
[limit, replaceListParams],
|
||||
);
|
||||
|
||||
const handleLimitChange = useCallback(
|
||||
(nextLimit: number) => {
|
||||
replaceListParams((params) => {
|
||||
params.delete('page');
|
||||
if (nextLimit === DEFAULT_LIMIT) {
|
||||
params.delete('limit');
|
||||
} else {
|
||||
params.set('limit', String(nextLimit));
|
||||
}
|
||||
});
|
||||
},
|
||||
[replaceListParams],
|
||||
);
|
||||
|
||||
const handleView = useCallback(
|
||||
(ticket: TicketListItem) => {
|
||||
const defectClassQuery = ticket.default_defect_class
|
||||
? `?defect_class=${encodeURIComponent(ticket.default_defect_class)}`
|
||||
: '';
|
||||
|
||||
router.push(`/ticket/${ticket.id}${defectClassQuery}`);
|
||||
const detailPath = ROUTES.TICKET_DETAIL(ticket.id);
|
||||
router.push(
|
||||
searchParamsString ? `${detailPath}?${searchParamsString}` : detailPath,
|
||||
);
|
||||
},
|
||||
[router],
|
||||
[router, searchParamsString],
|
||||
);
|
||||
|
||||
const toolbar = useMemo(
|
||||
@@ -51,6 +130,7 @@ export default function TicketPage() {
|
||||
<TicketFilters
|
||||
segmentId={segmentId}
|
||||
onSegmentChange={handleSegmentChange}
|
||||
onClear={() => handleSegmentChange('')}
|
||||
/>
|
||||
),
|
||||
[handleSegmentChange, segmentId],
|
||||
@@ -64,6 +144,12 @@ export default function TicketPage() {
|
||||
icon={Ticket}
|
||||
/>
|
||||
|
||||
<TicketSummaryCards
|
||||
summary={summaryQuery.data}
|
||||
isLoading={summaryQuery.isLoading}
|
||||
isError={summaryQuery.isError}
|
||||
/>
|
||||
|
||||
<TicketTable
|
||||
columns={columns}
|
||||
tickets={tickets}
|
||||
@@ -72,8 +158,8 @@ export default function TicketPage() {
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
onView={handleView}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
DetectionCoordinatesParams,
|
||||
TicketAssignmentDetectionsParams,
|
||||
TicketListParams,
|
||||
VideoDetectionsParams,
|
||||
} from '@/types';
|
||||
@@ -8,37 +8,30 @@ export const ticketKeys = {
|
||||
all: ['tickets'] as const,
|
||||
lists: () => [...ticketKeys.all, 'list'] as const,
|
||||
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
|
||||
summaries: () => [...ticketKeys.all, 'summary'] as const,
|
||||
summary: (params: { chainage_id?: string }) =>
|
||||
[...ticketKeys.summaries(), params] as const,
|
||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||
overview: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
||||
classDetail: (ticketId: string, defectClass: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
||||
timeline: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'timeline'] as const,
|
||||
classDetectionLists: (videoId: string) =>
|
||||
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||
[...ticketKeys.classDetectionLists(videoId), params] as const,
|
||||
detectionCoordinateLists: (videoId: string) =>
|
||||
[
|
||||
...ticketKeys.details(),
|
||||
'video',
|
||||
videoId,
|
||||
'detection-coordinates',
|
||||
] as const,
|
||||
detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) =>
|
||||
[...ticketKeys.detectionCoordinateLists(videoId), params] as const,
|
||||
classReviewDetections: (
|
||||
videoId: string,
|
||||
defectClass: string,
|
||||
proofStatus?: string,
|
||||
) =>
|
||||
reviewDetections: (videoId: string, proofStatus?: string) =>
|
||||
[
|
||||
...ticketKeys.classDetectionLists(videoId),
|
||||
'class-review',
|
||||
defectClass,
|
||||
'review',
|
||||
proofStatus ?? 'all',
|
||||
] as const,
|
||||
defectClasses: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||
assignmentDetectionLists: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'assignment-detections'] as const,
|
||||
assignmentDetections: (
|
||||
ticketId: string,
|
||||
params: TicketAssignmentDetectionsParams,
|
||||
) => [...ticketKeys.assignmentDetectionLists(ticketId), params] as const,
|
||||
assignments: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'assignments'] as const,
|
||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
|
||||
},
|
||||
{
|
||||
id: 'uploaded_by',
|
||||
header: 'Uploader',
|
||||
header: 'Uploaded By',
|
||||
size: 190,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
|
||||
@@ -69,6 +69,7 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={
|
||||
row.original.effective_status === 'active'
|
||||
? 'default'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { Edit, RotateCcw } from 'lucide-react';
|
||||
import { Edit, UserCheck, UserX } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
@@ -59,7 +59,12 @@ export function UserTable({
|
||||
{
|
||||
label: (user) =>
|
||||
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
|
||||
icon: <RotateCcw className="size-4" />,
|
||||
icon: (user) =>
|
||||
user.effective_status === 'active' ? (
|
||||
<UserX className="size-4 text-destructive" />
|
||||
) : (
|
||||
<UserCheck className="size-4 text-emerald-600" />
|
||||
),
|
||||
permission: PERMISSIONS.USER.DELETE,
|
||||
disabled: (user) =>
|
||||
pendingUserId === user.id || user.effective_status === 'pending',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import './typography.css';
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.225rem;
|
||||
--radius: 0.1rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
@@ -140,6 +140,40 @@
|
||||
|
||||
* {
|
||||
@apply outline-none border-border outline-ring/50;
|
||||
scrollbar-color: color-mix(
|
||||
in oklab,
|
||||
var(--muted-foreground) 45%,
|
||||
transparent
|
||||
)
|
||||
transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track,
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--muted-foreground) 45%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--muted-foreground) 70%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -152,7 +186,8 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
button:not(:disabled), [role="button"]:not(:disabled) {
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -164,4 +199,19 @@
|
||||
.detection-video-player
|
||||
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-tooltip.range-point-label {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
font-weight: 700;
|
||||
text-shadow:
|
||||
0 1px 2px rgb(255 255 255 / 95%),
|
||||
0 0 4px rgb(255 255 255 / 95%);
|
||||
}
|
||||
|
||||
.leaflet-tooltip.range-point-label::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -124,6 +132,7 @@ function SidebarNavItem({
|
||||
item: MenuItem;
|
||||
pathname: string;
|
||||
}) {
|
||||
const { isMobile, state } = useSidebar();
|
||||
const isActive = isRouteActive(pathname, item.path);
|
||||
const isChildActive =
|
||||
item.children?.some((child) => isRouteActive(pathname, child.path)) ??
|
||||
@@ -136,6 +145,52 @@ function SidebarNavItem({
|
||||
}, [isChildActive]);
|
||||
|
||||
if (hasChildren) {
|
||||
if (state === 'collapsed' && !isMobile) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
isActive={isChildActive}
|
||||
aria-label={item.title}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="min-w-48"
|
||||
>
|
||||
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{item.children?.map((child) =>
|
||||
child.path ? (
|
||||
<DropdownMenuItem
|
||||
key={child.title}
|
||||
asChild
|
||||
className={
|
||||
isRouteActive(pathname, child.path)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Link href={child.path}>
|
||||
<child.icon />
|
||||
<span>{child.title}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null,
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
|
||||
@@ -30,9 +30,11 @@ export function TableFooter<TData>({
|
||||
pageSize?: number;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
}) {
|
||||
const currentPageSize = pageSize ?? table.getState().pagination.pageSize;
|
||||
const paginationState = table.getState().pagination;
|
||||
const currentPageSize =
|
||||
pageSize ?? paginationState?.pageSize ?? PAGE_SIZE_OPTIONS[0];
|
||||
const selectedRowCount = Object.keys(table.getState().rowSelection).length;
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
const pageIndex = paginationState?.pageIndex ?? 0;
|
||||
const rawPageCount = table.getPageCount();
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { getPinnedColumnStyles } from './columnStyles';
|
||||
|
||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||
return (
|
||||
<ShadTableHeader className="sticky top-0 z-20 bg-muted/70 backdrop-blur supports-backdrop-filter:bg-muted/60">
|
||||
<ShadTableHeader className="sticky top-0 z-20 bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
@@ -39,7 +39,7 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||
canSort ? header.column.getToggleSortingHandler() : undefined
|
||||
}
|
||||
className={cn(
|
||||
'h-11 max-w-0 overflow-hidden border-b border-border bg-muted/70 px-4 text-sm font-semibold text-foreground transition-colors',
|
||||
'h-11 max-w-0 overflow-hidden border-b border-border bg-muted px-4 text-sm font-semibold text-foreground transition-colors',
|
||||
canSort && 'cursor-pointer select-none hover:bg-muted',
|
||||
sortDirection && 'bg-muted',
|
||||
)}
|
||||
|
||||
@@ -41,10 +41,11 @@ export type ColumnAlign = 'left' | 'right';
|
||||
|
||||
export interface DataTableAction<TData> {
|
||||
label: string | ((item: TData) => string);
|
||||
icon: React.ReactNode;
|
||||
icon: React.ReactNode | ((item: TData) => React.ReactNode);
|
||||
onClick: (item: TData) => void;
|
||||
permission?: PermissionInput;
|
||||
disabled?: (item: TData) => boolean;
|
||||
hidden?: (item: TData) => boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -156,10 +157,16 @@ function DataTableContent<TData, TValue>({
|
||||
return (
|
||||
<div className="flex justify-start gap-2">
|
||||
{actions.map((action) => {
|
||||
if (action.hidden?.(item)) return null;
|
||||
|
||||
const label =
|
||||
typeof action.label === 'function'
|
||||
? action.label(item)
|
||||
: action.label;
|
||||
const icon =
|
||||
typeof action.icon === 'function'
|
||||
? action.icon(item)
|
||||
: action.icon;
|
||||
|
||||
return (
|
||||
<TableActionButton
|
||||
@@ -173,7 +180,7 @@ function DataTableContent<TData, TValue>({
|
||||
action.onClick(item);
|
||||
}}
|
||||
>
|
||||
{action.icon}
|
||||
{icon}
|
||||
</TableActionButton>
|
||||
);
|
||||
})}
|
||||
@@ -212,37 +219,41 @@ function DataTableContent<TData, TValue>({
|
||||
manualSorting: isManualSorting,
|
||||
pageCount: pagination
|
||||
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
|
||||
: -1,
|
||||
: undefined,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
columnPinning,
|
||||
columnSizing,
|
||||
sorting,
|
||||
pagination: pagination
|
||||
...(pagination
|
||||
? {
|
||||
pagination: {
|
||||
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||
pageSize: pagination.limit,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
onPaginationChange: pagination
|
||||
? (updater) => {
|
||||
const currentState = {
|
||||
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||
pageSize: pagination.limit,
|
||||
};
|
||||
const newState =
|
||||
typeof updater === 'function' ? updater(currentState) : updater;
|
||||
const pageSizeChanged = newState.pageSize !== pagination.limit;
|
||||
|
||||
if (pageSizeChanged) {
|
||||
pagination.onLimitChange(newState.pageSize);
|
||||
pagination.onPageChange(0);
|
||||
return;
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
if (typeof updater === 'function' && pagination) {
|
||||
const newState = updater({
|
||||
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||
pageSize: pagination.limit,
|
||||
});
|
||||
const pageSizeChanged = newState.pageSize !== pagination.limit;
|
||||
|
||||
if (pageSizeChanged) {
|
||||
pagination.onLimitChange(newState.pageSize);
|
||||
pagination.onPageChange(0);
|
||||
return;
|
||||
pagination.onPageChange(newState.pageIndex * newState.pageSize);
|
||||
}
|
||||
|
||||
pagination.onPageChange(newState.pageIndex * newState.pageSize);
|
||||
}
|
||||
},
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,11 +41,7 @@ export default function AnnotatedDetectionImage({
|
||||
]
|
||||
: [];
|
||||
|
||||
const aspectRatio =
|
||||
aspectRatioOverride ??
|
||||
(videoWidth > 0 && videoHeight > 0
|
||||
? `${videoWidth} / ${videoHeight}`
|
||||
: '16 / 9');
|
||||
const aspectRatio = aspectRatioOverride ?? '16 / 9';
|
||||
|
||||
const annotatedMedia = objectUrl ? (
|
||||
<>
|
||||
@@ -75,14 +71,7 @@ export default function AnnotatedDetectionImage({
|
||||
className="group relative flex w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border bg-black focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<Image
|
||||
src={objectUrl}
|
||||
alt={`${detection.detection.display_name} detection`}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(min-width: 1280px) 18rem, (min-width: 640px) 50vw, 100vw"
|
||||
className="object-contain"
|
||||
/>
|
||||
{annotatedMedia}
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-8 text-center text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
Click to view annotated image
|
||||
</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Users,
|
||||
Building2,
|
||||
BadgeIndianRupee,
|
||||
CalendarClock,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
@@ -37,6 +38,12 @@ export const menuItems: MenuItem[] = [
|
||||
icon: Ticket,
|
||||
permission: PERMISSIONS.TICKET.READ,
|
||||
},
|
||||
{
|
||||
title: 'Extension Requests',
|
||||
path: ROUTES.EXTENSION_REQUESTS,
|
||||
icon: CalendarClock,
|
||||
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||
},
|
||||
{
|
||||
title: 'Project',
|
||||
path: ROUTES.PROJECT,
|
||||
|
||||
@@ -57,21 +57,22 @@ export const API_ROUTES = {
|
||||
ANNOTATION_FRAMES: (id: string) =>
|
||||
`/biz/api/v1/results/${id}/annotation-frames`,
|
||||
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
||||
COORDINATES: (id: string) => `/biz/api/v1/results/${id}/coordinates`,
|
||||
},
|
||||
DETECTIONS: {
|
||||
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,
|
||||
},
|
||||
TICKETS: {
|
||||
BASE: '/biz/api/v1/tickets',
|
||||
SUMMARY: '/biz/api/v1/tickets/summary',
|
||||
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
||||
CLASS_DETAIL: (id: string, defectClass: string) =>
|
||||
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
||||
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
||||
ASSIGNMENT_DETECTIONS: (id: string) =>
|
||||
`/biz/api/v1/tickets/${id}/assignment-detections`,
|
||||
TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
|
||||
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
|
||||
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
||||
EXTENSION_REQUESTS: (ticketId: string) =>
|
||||
EXTENSION_REQUESTS: '/biz/api/v1/tickets/extension-requests',
|
||||
TICKET_EXTENSION_REQUESTS: (ticketId: string) =>
|
||||
`/biz/api/v1/tickets/${ticketId}/extension-requests`,
|
||||
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
|
||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { TicketDefectClassTicketStatus } from '@/types';
|
||||
|
||||
export const DEFECT_CLASS_STATUS_CONFIG: Record<
|
||||
TicketDefectClassTicketStatus,
|
||||
{
|
||||
label: string;
|
||||
badgeClassName: string;
|
||||
}
|
||||
> = {
|
||||
unassigned: {
|
||||
label: 'Unassigned',
|
||||
badgeClassName:
|
||||
'bg-zinc-500/15 text-zinc-600 dark:bg-zinc-500/20 dark:text-zinc-300',
|
||||
},
|
||||
assigned: {
|
||||
label: 'Assigned',
|
||||
badgeClassName:
|
||||
'bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400',
|
||||
},
|
||||
under_review: {
|
||||
label: 'Under Review',
|
||||
badgeClassName:
|
||||
'bg-violet-500/15 text-violet-600 dark:bg-violet-500/20 dark:text-violet-400',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
badgeClassName:
|
||||
'bg-emerald-500/15 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
badgeClassName:
|
||||
'bg-orange-500/15 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400',
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||
import type {
|
||||
DetectionCoordinatesParams,
|
||||
DetectionCoordinatesResponse,
|
||||
DiscardDetectionPayload,
|
||||
DiscardDetectionResponse,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
@@ -11,36 +9,6 @@ import type {
|
||||
import axiosClient from '../axios/axios';
|
||||
|
||||
export const detectionService = {
|
||||
getCoordinates: async (
|
||||
videoId: string,
|
||||
params?: DetectionCoordinatesParams,
|
||||
): Promise<DetectionCoordinatesResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
params?.class_name?.forEach((className) => {
|
||||
searchParams.append('class_name', className);
|
||||
});
|
||||
if (params?.skip !== undefined) {
|
||||
searchParams.set('skip', String(params.skip));
|
||||
}
|
||||
if (params?.limit !== undefined) {
|
||||
searchParams.set('limit', String(params.limit));
|
||||
}
|
||||
if (params?.assignment_status) {
|
||||
searchParams.set('assignment_status', params.assignment_status);
|
||||
}
|
||||
if (params?.search) {
|
||||
searchParams.set('search', params.search);
|
||||
}
|
||||
|
||||
const response = await axiosClient.get<DetectionCoordinatesResponse>(
|
||||
API_ROUTES.VIDEOS.COORDINATES(videoId),
|
||||
{ params: searchParams },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
discardDetection: async (
|
||||
detectionId: number,
|
||||
payload: DiscardDetectionPayload,
|
||||
|
||||
@@ -8,14 +8,18 @@ import type {
|
||||
RequestTicketExtensionPayload,
|
||||
ReviewTicketExtensionPayload,
|
||||
SseTokenResponse,
|
||||
TicketDetail,
|
||||
TicketAssignmentSummary,
|
||||
TicketAssignmentDetectionsParams,
|
||||
TicketAssignmentDetectionsResponse,
|
||||
TicketAssignmentsResponse,
|
||||
TicketExtensionRequestsResponse,
|
||||
TicketExtensionRequestsParams,
|
||||
TicketOverviewDetail,
|
||||
TicketDefectClassesResponse,
|
||||
TicketListParams,
|
||||
TicketListResponse,
|
||||
TicketSummaryParams,
|
||||
TicketSummaryResponse,
|
||||
TicketTimelineResponse,
|
||||
} from '@/types';
|
||||
|
||||
type TicketAssignmentsApiResponse =
|
||||
@@ -43,6 +47,20 @@ export const ticketService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketSummary: async (
|
||||
params?: TicketSummaryParams,
|
||||
): Promise<TicketSummaryResponse> => {
|
||||
const response = await axiosClient.get<TicketSummaryResponse>(
|
||||
API_ROUTES.TICKETS.SUMMARY,
|
||||
{
|
||||
params: {
|
||||
chainage_id: params?.chainage_id,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketOverview: async (
|
||||
ticketId: string,
|
||||
): Promise<TicketOverviewDetail> => {
|
||||
@@ -52,21 +70,32 @@ export const ticketService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketClassDetail: async (
|
||||
getTicketAssignmentDetections: async (
|
||||
ticketId: string,
|
||||
defectClass: string,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.get<TicketDetail>(
|
||||
API_ROUTES.TICKETS.CLASS_DETAIL(ticketId, defectClass),
|
||||
params?: TicketAssignmentDetectionsParams,
|
||||
): Promise<TicketAssignmentDetectionsResponse> => {
|
||||
const response = await axiosClient.get<TicketAssignmentDetectionsResponse>(
|
||||
API_ROUTES.TICKETS.ASSIGNMENT_DETECTIONS(ticketId),
|
||||
{
|
||||
params: {
|
||||
assignment_status: params?.assignment_status ?? 'unassigned',
|
||||
class_name: params?.class_name,
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 500,
|
||||
},
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketDefectClasses: async (
|
||||
getTicketTimeline: async (
|
||||
ticketId: string,
|
||||
): Promise<TicketDefectClassesResponse> => {
|
||||
const response = await axiosClient.get<TicketDefectClassesResponse>(
|
||||
API_ROUTES.TICKETS.DEFECT_CLASSES(ticketId),
|
||||
): Promise<TicketTimelineResponse> => {
|
||||
const response = await axiosClient.get<TicketTimelineResponse>(
|
||||
API_ROUTES.TICKETS.TIMELINE(ticketId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -137,7 +166,7 @@ export const ticketService = {
|
||||
assignmentId: number,
|
||||
): Promise<TicketExtensionRequestsResponse> => {
|
||||
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
|
||||
API_ROUTES.TICKETS.EXTENSION_REQUESTS(ticketId),
|
||||
API_ROUTES.TICKETS.TICKET_EXTENSION_REQUESTS(ticketId),
|
||||
{
|
||||
params: { assignment_id: assignmentId },
|
||||
},
|
||||
@@ -145,6 +174,16 @@ export const ticketService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketExtensionRequests: async (
|
||||
params: TicketExtensionRequestsParams,
|
||||
): Promise<TicketExtensionRequestsResponse> => {
|
||||
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
|
||||
API_ROUTES.TICKETS.EXTENSION_REQUESTS,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reviewTicketExtension: async (
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
@@ -156,11 +195,12 @@ export const ticketService = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
closeTicket: async (
|
||||
ticketId: string,
|
||||
payload: CloseTicketPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
): Promise<unknown> => {
|
||||
const response = await axiosClient.post<unknown>(
|
||||
API_ROUTES.TICKETS.CLOSE(ticketId),
|
||||
payload,
|
||||
);
|
||||
|
||||
@@ -74,6 +74,7 @@ export const videoService = {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 1,
|
||||
assignment_id: params?.assignment_id,
|
||||
assignment_status: params?.assignment_status,
|
||||
class_name: params?.class_name,
|
||||
proof_status: params?.proof_status,
|
||||
min_confidence: params?.min_confidence,
|
||||
|
||||
@@ -5,38 +5,6 @@ export type DetectionClassCount = {
|
||||
unique_count: number;
|
||||
};
|
||||
|
||||
export type DetectionCoordinateClass = {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DetectionCoordinateItem = {
|
||||
id: number;
|
||||
class_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export type DetectionAssignmentStatus = 'assigned' | 'unassigned';
|
||||
|
||||
export type DetectionCoordinatesParams = {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
class_name?: string[];
|
||||
assignment_status?: DetectionAssignmentStatus;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type DetectionCoordinatesResponse = {
|
||||
total: number;
|
||||
limit: number;
|
||||
truncated: boolean;
|
||||
classes: DetectionCoordinateClass[];
|
||||
items: DetectionCoordinateItem[];
|
||||
};
|
||||
|
||||
export type DetectionBoundingBox = {
|
||||
x1: number;
|
||||
y1: number;
|
||||
@@ -107,10 +75,13 @@ export type DetectionResultLog = DetectionResultItem & {
|
||||
};
|
||||
};
|
||||
|
||||
export type AssignmentDetectionStatus = 'assigned' | 'unassigned';
|
||||
|
||||
export type VideoDetectionsParams = {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
assignment_id?: number;
|
||||
assignment_status?: AssignmentDetectionStatus;
|
||||
class_name?: string | string[];
|
||||
proof_status?: DetectionProofStatus;
|
||||
min_confidence?: number;
|
||||
@@ -136,6 +107,35 @@ export type VideoDetectionsResponse = {
|
||||
items: DetectionResultItem[];
|
||||
};
|
||||
|
||||
export type TicketAssignmentDetectionsParams = {
|
||||
assignment_status?: AssignmentDetectionStatus;
|
||||
class_name?: string | string[];
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type TicketAssignmentDetectionItem = {
|
||||
id: string;
|
||||
detection: {
|
||||
id: number;
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
};
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type TicketAssignmentDetectionsResponse = {
|
||||
ticket_id: string;
|
||||
assignment_status: AssignmentDetectionStatus;
|
||||
items: TicketAssignmentDetectionItem[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type DetectionDiscardReason =
|
||||
| 'not_a_defect'
|
||||
| 'wrong_class'
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface RequestTicketExtensionPayload {
|
||||
|
||||
export interface ReviewTicketExtensionPayload {
|
||||
action: 'approve' | 'reject';
|
||||
note: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TicketExtensionRequestActor {
|
||||
@@ -58,19 +58,33 @@ export interface TicketExtensionRequestActor {
|
||||
};
|
||||
}
|
||||
|
||||
export type TicketExtensionRequestStatus =
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'cancelled';
|
||||
|
||||
export interface TicketExtensionRequest {
|
||||
id: number;
|
||||
ticket_id: string;
|
||||
ticket_name: string;
|
||||
assignment_id: number;
|
||||
defect_class: string;
|
||||
defect_class?: string | null;
|
||||
current_due_at: string;
|
||||
requested_due_at: string;
|
||||
reason: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'cancelled';
|
||||
status: TicketExtensionRequestStatus;
|
||||
requested_by: TicketExtensionRequestActor | null;
|
||||
requested_at: string;
|
||||
resolved_by: TicketExtensionRequestActor | null;
|
||||
resolved_at: string | null;
|
||||
decision_note: string | null;
|
||||
history_meta: {
|
||||
previous_request_count: number;
|
||||
previous_approved_count: number;
|
||||
previous_rejected_count: number;
|
||||
previous_cancelled_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TicketExtensionRequestsResponse {
|
||||
@@ -78,6 +92,11 @@ export interface TicketExtensionRequestsResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TicketExtensionRequestsParams extends PaginationParams {
|
||||
ticket_name?: string;
|
||||
status?: TicketExtensionRequestStatus;
|
||||
}
|
||||
|
||||
export interface CloseTicketPayload {
|
||||
defect_class: string;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export type TicketDefectClassTicketStatus =
|
||||
| 'unassigned'
|
||||
| 'assigned'
|
||||
| 'under_review'
|
||||
| 'approved'
|
||||
| 'rejected';
|
||||
|
||||
export interface TicketDefectClassItem {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
assignment_status: TicketDefectClassTicketStatus;
|
||||
}
|
||||
|
||||
export interface TicketDefectClassesResponse {
|
||||
ticket_id: string;
|
||||
video_id: string;
|
||||
ticket_status: TicketDefectClassTicketStatus;
|
||||
defect_classes: TicketDefectClassItem[];
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { TicketDefectClassTicketStatus } from './defect-class';
|
||||
import type { TicketStatus } from './status';
|
||||
import type { TicketAssignmentCriteria } from './actions';
|
||||
|
||||
export interface TicketActor {
|
||||
@@ -9,38 +7,6 @@ export interface TicketActor {
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export type TicketHistorySource = 'system' | 'user';
|
||||
|
||||
export interface TicketHistoryNote {
|
||||
kind: string;
|
||||
author: TicketActor | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TicketHistoryItem {
|
||||
id: number;
|
||||
event_type: string;
|
||||
title: string;
|
||||
source: TicketHistorySource;
|
||||
assignment_id: number | null;
|
||||
defect_class: string | null;
|
||||
from_class_status: TicketDefectClassTicketStatus | null;
|
||||
to_class_status: TicketDefectClassTicketStatus | null;
|
||||
actor: TicketActor | null;
|
||||
target_user: TicketActor | null;
|
||||
note: TicketHistoryNote | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TicketStatusMetadata {
|
||||
reason: string | null;
|
||||
label: string | null;
|
||||
message: string | null;
|
||||
detection_count?: number | null;
|
||||
requires_action?: boolean;
|
||||
review_comment?: string | null;
|
||||
}
|
||||
|
||||
export interface TicketVideo {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -49,40 +15,6 @@ export interface TicketVideo {
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface TicketWorker extends TicketActor {
|
||||
assignment_id: number;
|
||||
assigned_at: string | null;
|
||||
due_at: string | null;
|
||||
assigned_by_user_id: number | null;
|
||||
assigned_by_name: string | null;
|
||||
role: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TicketRepair {
|
||||
notes: string | null;
|
||||
proof_video_url: string | null;
|
||||
proof_image_urls: string[];
|
||||
submitted_at: string | null;
|
||||
}
|
||||
|
||||
export interface TicketReviewer extends TicketActor {
|
||||
review_comment: string | null;
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export interface TicketAiResult {
|
||||
detection_count: number;
|
||||
available_defect_classes: string[];
|
||||
}
|
||||
|
||||
export interface TicketTenant {
|
||||
root_tenant_id: number;
|
||||
organization_id: number;
|
||||
}
|
||||
|
||||
export interface TicketTimestamps {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -128,6 +60,7 @@ export interface TicketAssignmentSummary {
|
||||
pending_detections: number;
|
||||
not_submitted_detections: number;
|
||||
is_complete: boolean;
|
||||
is_overdue: boolean;
|
||||
due_at: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -140,6 +73,8 @@ export interface TicketAssignmentsResponse {
|
||||
export interface TicketOverviewDetail {
|
||||
id: string;
|
||||
ticket_name?: string | null;
|
||||
has_pending_review: boolean;
|
||||
pending_review_count: number;
|
||||
video_id?: string | null;
|
||||
video?: TicketVideo | null;
|
||||
chainage_id?: string | null;
|
||||
@@ -160,25 +95,3 @@ export interface TicketOverviewDetail {
|
||||
assignments?: TicketAssignmentSummary[];
|
||||
timestamps?: TicketTimestamps | null;
|
||||
}
|
||||
|
||||
export interface TicketDetail {
|
||||
id: string;
|
||||
ticket_name?: string;
|
||||
video_id: string;
|
||||
chainage_id: string | null;
|
||||
chainage_name: string | null;
|
||||
status: TicketStatus;
|
||||
defect_class: string;
|
||||
defect_display_name: string;
|
||||
assignment_status: TicketDefectClassTicketStatus;
|
||||
status_metadata: TicketStatusMetadata | null;
|
||||
video: TicketVideo | null;
|
||||
uploader: TicketActor | null;
|
||||
worker: TicketWorker | null;
|
||||
repair: TicketRepair | null;
|
||||
reviewer: TicketReviewer | null;
|
||||
ai_result: TicketAiResult | null;
|
||||
tenant: TicketTenant | null;
|
||||
timestamps: TicketTimestamps;
|
||||
history: TicketHistoryItem[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TicketDetail } from './detail';
|
||||
import type { TicketStatus } from './status';
|
||||
import type { TicketAttentionSummary } from './list';
|
||||
|
||||
export interface SseTokenResponse {
|
||||
sse_token: string;
|
||||
@@ -15,14 +15,16 @@ export type TicketTableStatusEvent = {
|
||||
chainage_name?: string | null;
|
||||
default_defect_class?: string | null;
|
||||
detection_count?: number;
|
||||
is_closed?: boolean;
|
||||
created_by_name?: string | null;
|
||||
created_by_email?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
attention_summary?: TicketAttentionSummary;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type TicketDetailStatusEvent = Partial<TicketDetail> & {
|
||||
export type TicketDetailStatusEvent = {
|
||||
type: 'ticket_status' | 'heartbeat';
|
||||
kind?: 'snapshot' | 'transitioned';
|
||||
id?: string;
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from './status';
|
||||
export * from './list';
|
||||
export * from './detail';
|
||||
export * from './actions';
|
||||
export * from './defect-class';
|
||||
export * from './events';
|
||||
export * from './timeline';
|
||||
export * from './summary';
|
||||
|
||||
@@ -4,6 +4,13 @@ export interface TicketListParams extends PaginationParams {
|
||||
chainage_id?: string;
|
||||
}
|
||||
|
||||
export interface TicketAttentionSummary {
|
||||
has_pending_extension_request: boolean;
|
||||
pending_extension_request_count: number;
|
||||
has_pending_repair_review: boolean;
|
||||
pending_repair_review_count: number;
|
||||
}
|
||||
|
||||
export interface TicketListItem {
|
||||
id: string;
|
||||
ticket_name: string;
|
||||
@@ -11,10 +18,12 @@ export interface TicketListItem {
|
||||
chainage_name: string | null;
|
||||
default_defect_class: string | null;
|
||||
detection_count: number;
|
||||
is_closed: boolean;
|
||||
created_by_name: string | null;
|
||||
created_by_email: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
attention_summary: TicketAttentionSummary;
|
||||
}
|
||||
|
||||
export interface TicketListResponse {
|
||||
|
||||
13
src/types/ticket/summary.ts
Normal file
13
src/types/ticket/summary.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface TicketSummaryParams {
|
||||
chainage_id?: string;
|
||||
}
|
||||
|
||||
export interface TicketSummaryResponse {
|
||||
total_tickets: number;
|
||||
in_progress_tickets: number;
|
||||
pending_actions: {
|
||||
extension_requests: number;
|
||||
repair_reviews: number;
|
||||
};
|
||||
completed_tickets: number;
|
||||
}
|
||||
60
src/types/ticket/timeline.ts
Normal file
60
src/types/ticket/timeline.ts
Normal 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[];
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export const ROUTES = {
|
||||
ACCOUNT: '/account',
|
||||
UPLOAD: '/upload',
|
||||
TICKET: '/ticket',
|
||||
EXTENSION_REQUESTS: '/extension-requests',
|
||||
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
|
||||
TICKET_ASSIGN: (ticketId: string) => `/ticket/${ticketId}/assign`,
|
||||
TICKET_CLASS_REVIEW: (ticketId: string, defectClass: string) =>
|
||||
`/ticket/${ticketId}/${defectClass}/review`,
|
||||
TICKET_REVIEW: (ticketId: string) => `/ticket/${ticketId}/review`,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user