Compare commits
70 Commits
14220445ad
...
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 | |||
| e894676d3b | |||
| 31839d1c70 | |||
| e8f2955acf | |||
| 5e66b70611 | |||
| 7ffe3f0708 | |||
| 8e8803396d | |||
| 0601322361 | |||
| 20f611f35a | |||
| 224361f144 | |||
| 77b6755d05 | |||
| 2f25fd49be | |||
| 3891500cf2 | |||
| 062e89fcba | |||
| 725c256791 | |||
| 5d3dac0b21 | |||
| e077467bce | |||
| e94da54aa9 | |||
| 76c87811d8 | |||
| 830883c6b7 | |||
| 68829dc72b | |||
| 9d7f956c3b | |||
| 0026824f84 | |||
| a18eb64dac | |||
| 6c71d8f8a7 | |||
| 0e2172366b | |||
| 56180f8d18 | |||
| 5269b29aac | |||
| 9fe7109faf | |||
| 9720546455 | |||
| 8727963c54 | |||
| 0e8108b875 | |||
| 87dde1c8d1 | |||
| a376558b2f | |||
| 4fe93460d9 | |||
| cbfb6e040b | |||
| 7a82e17bbb | |||
| 23186ffd29 | |||
| fb9a654aea | |||
| d88978a830 | |||
| ba9731de86 | |||
| c0f3a718ef |
@@ -1,2 +1,2 @@
|
||||
# Backend API base URL (no trailing slash)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
# Server-only backend origin. Do not expose this as a NEXT_PUBLIC variable.
|
||||
BACKEND_ORIGIN=http://localhost:8000
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "radix-vega",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -18,5 +19,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const backendOrigin = process.env.BACKEND_ORIGIN?.replace(/\/+$/, '');
|
||||
|
||||
if (!backendOrigin) {
|
||||
throw new Error('BACKEND_ORIGIN is required');
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/:namespace(api|biz)/:path*',
|
||||
destination: `${backendOrigin}/:namespace/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
3381
package-lock.json
generated
3381
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"libphonenumber-js": "^1.13.7",
|
||||
"lightgallery": "^2.9.0",
|
||||
"lucide-react": "^0.548.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.0.10",
|
||||
@@ -42,7 +44,9 @@
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"recharts": "2.15.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
@@ -51,6 +55,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
5
public/color_logo.svg
Normal file
5
public/color_logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
public/logo.svg
Normal file
5
public/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -33,7 +33,7 @@ function ResetPasswordContent() {
|
||||
</header>
|
||||
|
||||
{!hasToken ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-destructive">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-destructive">
|
||||
Password reset token is missing.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
|
||||
|
||||
export function ModuleShellHeader() {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-backdrop-filter:bg-background/80">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SidebarTrigger className="shrink-0 bg-background/80 text-muted-foreground shadow-xs backdrop-blur-sm" />
|
||||
<Separator orientation="vertical" className="h-4 shrink-0" />
|
||||
</>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { FormField, PhoneInput } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -71,12 +72,12 @@ export function ClientSheet({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{clientId ? 'Edit Client' : 'Create Client'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage company and primary contact details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -86,7 +87,7 @@ export function ClientSheet({
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="max-h-[58vh] space-y-5 overflow-y-auto px-6 py-4">
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="client-first-name"
|
||||
@@ -240,9 +241,9 @@ export function ClientSheet({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AuthGuard } from '@/guards';
|
||||
import React from 'react';
|
||||
import { ModuleShellHeader } from './_components/module-shell-header';
|
||||
|
||||
const ModulesLayout = ({
|
||||
children,
|
||||
@@ -13,18 +11,13 @@ const ModulesLayout = ({
|
||||
}>) => {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden">
|
||||
<div className="scroll-stable flex-1 overflow-auto">
|
||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
|
||||
<SidebarTrigger />
|
||||
<ModeToggle />
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
<div className="p-6 flex-1">{children}</div>
|
||||
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="scroll-stable min-w-0 flex-1 overflow-auto">
|
||||
<div className="mx-auto flex min-h-full w-full min-w-0 max-w-380 flex-col">
|
||||
<ModuleShellHeader />
|
||||
<div className="min-w-0 flex-1 p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -62,14 +63,14 @@ export function PackageDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{packageId ? 'Edit Package' : 'Create Package'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage package details and optional chainage range.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -79,7 +80,7 @@ export function PackageDialog({
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
@@ -173,9 +174,9 @@ export function PackageDialog({
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Package as PackageIcon, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Package } from '@/types';
|
||||
|
||||
@@ -20,6 +30,7 @@ import {
|
||||
|
||||
export default function PackagePage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = usePackageFilters();
|
||||
const packagesQuery = usePackagesQuery({ skip, limit });
|
||||
const projectsQuery = useProjectOptionsQuery();
|
||||
@@ -67,14 +78,25 @@ export default function PackagePage() {
|
||||
|
||||
const deletePackage = useCallback(
|
||||
(pkg: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
deletePackageMutation.mutate(pkg);
|
||||
setPackageToDelete(pkg);
|
||||
},
|
||||
[deletePackageMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setPackageToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeletePackage = useCallback(() => {
|
||||
if (!packageToDelete) return;
|
||||
|
||||
deletePackageMutation.mutate(packageToDelete, {
|
||||
onSettled: () => setPackageToDelete(null),
|
||||
});
|
||||
}, [deletePackageMutation, packageToDelete]);
|
||||
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const packages = packagesQuery.data?.items ?? [];
|
||||
const total = packagesQuery.data?.totalItems ?? 0;
|
||||
@@ -122,6 +144,36 @@ export default function PackagePage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(packageToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete package?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{packageToDelete?.name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deletePackageMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePackageMutation.isPending}
|
||||
onClick={confirmDeletePackage}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function PlanSheet({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full gap-0 p-0 sm:max-w-3xl">
|
||||
<SheetContent className="w-full! gap-0 p-0 sm:max-w-3xl! lg:max-w-4xl!">
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
{planId ? 'Edit Plan' : 'Create Plan'}
|
||||
@@ -77,7 +77,11 @@ export function PlanSheet({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
@@ -242,7 +246,7 @@ export function PlanSheet({
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
className="size-4 rounded-lg border-border accent-primary"
|
||||
{...register('is_active')}
|
||||
/>
|
||||
Active
|
||||
@@ -250,7 +254,7 @@ export function PlanSheet({
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
className="size-4 rounded-lg border-border accent-primary"
|
||||
{...register('is_custom')}
|
||||
/>
|
||||
Custom plan
|
||||
@@ -269,7 +273,7 @@ export function PlanSheet({
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FormField } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -54,14 +55,14 @@ export function ProjectDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{projectId ? 'Edit Project' : 'Create Project'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage road project identity and optional coordinate boundaries.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -71,7 +72,7 @@ export function ProjectDialog({
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
@@ -192,9 +193,9 @@ export function ProjectDialog({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Layers, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Project } from '@/types';
|
||||
|
||||
@@ -19,6 +29,7 @@ import {
|
||||
|
||||
export default function ProjectPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = useProjectFilters();
|
||||
const projectsQuery = useProjectsQuery({ skip, limit });
|
||||
const deleteProjectMutation = useDeleteProjectMutation();
|
||||
@@ -63,16 +74,25 @@ export default function ProjectPage() {
|
||||
|
||||
const deleteProject = useCallback(
|
||||
(project: Project) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete project "${project.name}"?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteProjectMutation.mutate(project);
|
||||
setProjectToDelete(project);
|
||||
},
|
||||
[deleteProjectMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setProjectToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeleteProject = useCallback(() => {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
deleteProjectMutation.mutate(projectToDelete, {
|
||||
onSettled: () => setProjectToDelete(null),
|
||||
});
|
||||
}, [deleteProjectMutation, projectToDelete]);
|
||||
|
||||
const columns = useProjectColumns();
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const total = projectsQuery.data?.totalItems ?? 0;
|
||||
@@ -117,6 +137,36 @@ export default function ProjectPage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(projectToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{projectToDelete?.name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={confirmDeleteProject}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
function StatSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center">
|
||||
<Skeleton className="mb-2 size-9 rounded-md" />
|
||||
<Skeleton className="mb-2 size-9 rounded-lg" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="mt-2 h-3 w-20" />
|
||||
</div>
|
||||
@@ -14,9 +14,9 @@ function StatSkeleton() {
|
||||
|
||||
function DetectionLogSkeleton() {
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-3">
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-20 shrink-0 rounded-md" />
|
||||
<Skeleton className="size-20 shrink-0 rounded-lg" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
@@ -41,7 +41,7 @@ export function VideoResultsSkeleton() {
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Skeleton className="size-10 rounded-md" />
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-7 w-60 max-w-full" />
|
||||
<Skeleton className="h-3 w-80 max-w-full" />
|
||||
@@ -50,10 +50,10 @@ export function VideoResultsSkeleton() {
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
|
||||
<section className="overflow-hidden rounded-xl border bg-card">
|
||||
<section className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="border-b p-6 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded" />
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-44" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
@@ -76,7 +76,7 @@ export function VideoResultsSkeleton() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full rounded-md" />
|
||||
<Skeleton key={index} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { videoService } from '@/services/api';
|
||||
import type { VideoAnnotationFramesParams } from '@/types';
|
||||
|
||||
import { videoKeys } from '../queries/videoKeys';
|
||||
|
||||
@@ -18,6 +19,20 @@ export function useVideoResultsQuery(videoId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useVideoAnnotationFramesQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoAnnotationFramesParams,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: videoKeys.annotationFrames(videoId ?? '', params),
|
||||
queryFn: () =>
|
||||
videoService.getVideoAnnotationFrames(videoId as string, params),
|
||||
enabled: Boolean(videoId),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the annotated video through the authenticated axios client and
|
||||
* expose a local object URL that the native `<video>` element can play.
|
||||
@@ -26,10 +41,10 @@ export function useVideoResultsQuery(videoId: string | undefined) {
|
||||
* directly is what fixes the 401. The browser cannot attach the Bearer token to
|
||||
* a media request, but axios can.
|
||||
*/
|
||||
export function useAnnotatedVideoQuery(url: string | undefined) {
|
||||
export function useProtectedVideoQuery(url: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: videoKeys.annotatedVideo(url ?? ''),
|
||||
queryFn: () => videoService.getAnnotatedVideo(url as string),
|
||||
queryFn: () => videoService.getProtectedVideo(url as string),
|
||||
enabled: Boolean(url),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
@@ -59,3 +74,5 @@ export function useAnnotatedVideoQuery(url: string | undefined) {
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export const useAnnotatedVideoQuery = useProtectedVideoQuery;
|
||||
|
||||
@@ -2,6 +2,10 @@ export const videoKeys = {
|
||||
all: ['videos'] as const,
|
||||
results: () => [...videoKeys.all, 'results'] as const,
|
||||
result: (videoId: string) => [...videoKeys.results(), videoId] as const,
|
||||
annotationFrames: (
|
||||
videoId: string,
|
||||
params: { start_time_ms: number; end_time_ms: number },
|
||||
) => [...videoKeys.result(videoId), 'annotation-frames', params] as const,
|
||||
annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
|
||||
annotatedVideo: (url: string) =>
|
||||
[...videoKeys.annotatedVideos(), url] as const,
|
||||
|
||||
@@ -62,7 +62,7 @@ export function RoleSheet({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-2xl"
|
||||
className="flex h-full w-full! flex-col gap-0 p-0 sm:max-w-3xl!"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
@@ -73,7 +73,11 @@ export function RoleSheet({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
@@ -125,7 +129,7 @@ export function RoleSheet({
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -71,14 +72,14 @@ export function SegmentDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage segment binding, chainage values, direction, and coordinates.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -88,7 +89,7 @@ export function SegmentDialog({
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
@@ -300,9 +301,9 @@ export function SegmentDialog({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Milestone, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Chainage } from '@/types';
|
||||
|
||||
@@ -21,6 +31,7 @@ import {
|
||||
|
||||
export default function SegmentPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = useSegmentFilters();
|
||||
const segmentsQuery = useSegmentsQuery({ skip, limit });
|
||||
const projectsQuery = useProjectOptionsQuery();
|
||||
@@ -75,18 +86,25 @@ export default function SegmentPage() {
|
||||
|
||||
const deleteSegment = useCallback(
|
||||
(segment: Chainage) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete segment "${segment.segment_name}"?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteSegmentMutation.mutate(segment);
|
||||
setSegmentToDelete(segment);
|
||||
},
|
||||
[deleteSegmentMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setSegmentToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeleteSegment = useCallback(() => {
|
||||
if (!segmentToDelete) return;
|
||||
|
||||
deleteSegmentMutation.mutate(segmentToDelete, {
|
||||
onSettled: () => setSegmentToDelete(null),
|
||||
});
|
||||
}, [deleteSegmentMutation, segmentToDelete]);
|
||||
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const allPackages = allPackagesQuery.data?.items ?? [];
|
||||
const segments = segmentsQuery.data?.items ?? [];
|
||||
@@ -139,6 +157,36 @@ export default function SegmentPage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(segmentToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete segment?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{segmentToDelete?.segment_name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteSegmentMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteSegmentMutation.isPending}
|
||||
onClick={confirmDeleteSegment}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FormField, PhoneInput, SelectPopover } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -98,12 +99,12 @@ export function TenantSheet({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Bind a client and subscription plan, then invite the tenant
|
||||
administrator.
|
||||
</DialogDescription>
|
||||
@@ -114,7 +115,7 @@ export function TenantSheet({
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p>Basic Information</p>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -227,7 +228,7 @@ export function TenantSheet({
|
||||
id="tenant-description"
|
||||
placeholder="North zone operations"
|
||||
{...register('description')}
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||
className="min-h-20 w-full rounded-lg border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -314,7 +315,7 @@ export function TenantSheet({
|
||||
</div>
|
||||
</>
|
||||
) : adminEmail ? (
|
||||
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-muted-foreground">
|
||||
Admin invitation details cannot be changed after tenant
|
||||
creation.
|
||||
<p className="mt-1 text-foreground">
|
||||
@@ -322,9 +323,9 @@ export function TenantSheet({
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { AssignableTicketUser } from '@/types';
|
||||
|
||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||
import {
|
||||
formatCompactRangeCoordinate,
|
||||
formatRangeCoordinate,
|
||||
type AssignmentRangePoint,
|
||||
} from './assignmentRange';
|
||||
|
||||
interface AssignTicketFormProps {
|
||||
ticketId: string;
|
||||
selectedClassNames: string[];
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onAssigned: () => void;
|
||||
}
|
||||
|
||||
export function AssignTicketForm({
|
||||
ticketId,
|
||||
selectedClassNames,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onAssigned,
|
||||
}: AssignTicketFormProps) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedContractor, setSelectedContractor] =
|
||||
useState<AssignableTicketUser | null>(null);
|
||||
const [dueDate, setDueDate] = useState<Date>();
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticketId);
|
||||
const today = useMemo(() => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}, []);
|
||||
const maxDueMonth = useMemo(
|
||||
() => new Date(today.getFullYear() + 20, 11),
|
||||
[today],
|
||||
);
|
||||
const hasIncompleteLocationRange = Boolean(startPoint) !== Boolean(endPoint);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="text-primary" />
|
||||
Contractor and schedule
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Contractor <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<AssignableWorkerSelect
|
||||
value={selectedUserId}
|
||||
onValueChange={(userId, user) => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedContractor(user);
|
||||
}}
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedContractor ? (
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-4">
|
||||
<p className="text-sm font-medium">Contractor details</p>
|
||||
{selectedContractor.phone_number ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Phone className="size-4 shrink-0" />
|
||||
{selectedContractor.phone_number}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="size-4 shrink-0" />
|
||||
<span className="truncate">{selectedContractor.email}</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
{selectedContractor.role.name}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{startPoint || endPoint ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2 text-xs"
|
||||
title={[
|
||||
startPoint ? formatRangeCoordinate(startPoint) : null,
|
||||
endPoint ? formatRangeCoordinate(endPoint) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' to ')}
|
||||
>
|
||||
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 font-mono">
|
||||
{startPoint
|
||||
? formatCompactRangeCoordinate(startPoint)
|
||||
: endPoint
|
||||
? formatCompactRangeCoordinate(endPoint)
|
||||
: null}
|
||||
</span>
|
||||
{startPoint && endPoint ? (
|
||||
<>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 font-mono">
|
||||
{formatCompactRangeCoordinate(endPoint)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{hasIncompleteLocationRange ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{startPoint
|
||||
? 'Select an end location to complete the range.'
|
||||
: 'Select a start location to complete the range.'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assign-due-date">
|
||||
Due date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePickerSimple
|
||||
id="assign-due-date"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
showLabel={false}
|
||||
placeholder="Select due date"
|
||||
disabled={assignMutation.isPending}
|
||||
className="w-full"
|
||||
startMonth={today}
|
||||
endMonth={maxDueMonth}
|
||||
disabledDates={{ before: today }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assign-note">Assignment note (optional)</Label>
|
||||
<Textarea
|
||||
id="assign-note"
|
||||
value={assignNote}
|
||||
onChange={(event) => setAssignNote(event.target.value)}
|
||||
placeholder="Add instructions or notes for the contractor..."
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!selectedUserId ||
|
||||
!selectedContractor ||
|
||||
!dueDate ||
|
||||
hasIncompleteLocationRange ||
|
||||
assignMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
!selectedUserId ||
|
||||
!selectedContractor ||
|
||||
!dueDate ||
|
||||
hasIncompleteLocationRange
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = assignNote.trim() || undefined;
|
||||
const hasCriteria =
|
||||
selectedClassNames.length > 0 ||
|
||||
Boolean(startPoint && endPoint);
|
||||
|
||||
assignMutation.mutate(
|
||||
{
|
||||
assigned_to_user_id: Number(selectedUserId),
|
||||
assigned_to_email: selectedContractor.email,
|
||||
due_at: dueDate.toISOString(),
|
||||
note,
|
||||
...(hasCriteria
|
||||
? {
|
||||
criteria: {
|
||||
...(selectedClassNames.length > 0
|
||||
? { class_names: selectedClassNames }
|
||||
: {}),
|
||||
...(startPoint && endPoint
|
||||
? {
|
||||
start_lat: startPoint.latitude,
|
||||
start_lng: startPoint.longitude,
|
||||
end_lat: endPoint.latitude,
|
||||
end_lng: endPoint.longitude,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedUserId('');
|
||||
setSelectedContractor(null);
|
||||
setDueDate(undefined);
|
||||
setAssignNote('');
|
||||
onAssigned();
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Send />
|
||||
{assignMutation.isPending ? 'Assigning...' : 'Assign ticket'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type {
|
||||
TicketAssignmentDetectionItem,
|
||||
TicketAssignmentDetectionsResponse,
|
||||
} from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
|
||||
type AssignmentIssuesMapProps = {
|
||||
data?: TicketAssignmentDetectionsResponse;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
type AssignmentMapItem = {
|
||||
id: number;
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
type AssignmentMapClass = {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
type ClientAssignmentIssuesMapProps = {
|
||||
items: AssignmentMapItem[];
|
||||
classes: AssignmentMapClass[];
|
||||
selectedDetectionId: number | null;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSelectDetection: (detectionId: number) => void;
|
||||
};
|
||||
|
||||
function toMapItem(
|
||||
item: TicketAssignmentDetectionItem,
|
||||
sequence: number,
|
||||
): AssignmentMapItem | null {
|
||||
const { latitude, longitude } = item.location;
|
||||
if (
|
||||
typeof latitude !== 'number' ||
|
||||
typeof longitude !== 'number' ||
|
||||
!Number.isFinite(latitude) ||
|
||||
!Number.isFinite(longitude) ||
|
||||
latitude < -90 ||
|
||||
latitude > 90 ||
|
||||
longitude < -180 ||
|
||||
longitude > 180
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.detection.id,
|
||||
class_name: item.detection.class_name,
|
||||
display_name: item.detection.display_name,
|
||||
latitude,
|
||||
longitude,
|
||||
sequence,
|
||||
};
|
||||
}
|
||||
|
||||
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
async () => {
|
||||
const {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
useMap,
|
||||
} = await import('react-leaflet');
|
||||
const { canvas } = await import('leaflet');
|
||||
|
||||
function FitMapToIssues({ items }: { items: AssignmentMapItem[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
|
||||
if (items.length === 1) {
|
||||
map.setView([items[0].latitude, items[0].longitude], 17);
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
items.map(
|
||||
(item) => [item.latitude, item.longitude] as [number, number],
|
||||
),
|
||||
{
|
||||
padding: [32, 32],
|
||||
maxZoom: 17,
|
||||
},
|
||||
);
|
||||
}, [items, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return function ClientAssignmentIssuesMapInner({
|
||||
items,
|
||||
classes,
|
||||
selectedDetectionId,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSelectDetection,
|
||||
}: ClientAssignmentIssuesMapProps) {
|
||||
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
|
||||
const displayNames = new Map(
|
||||
classes.map((item) => [item.class_name, item.display_name]),
|
||||
);
|
||||
const initialCenter: [number, number] = [
|
||||
items[0].latitude,
|
||||
items[0].longitude,
|
||||
];
|
||||
const orderedItems = [...items].sort(
|
||||
(first, second) =>
|
||||
first.sequence - second.sequence || first.id - second.id,
|
||||
);
|
||||
const startIndex = orderedItems.findIndex(
|
||||
(item) => item.id === startPoint?.detectionId,
|
||||
);
|
||||
const endIndex = orderedItems.findIndex(
|
||||
(item) => item.id === endPoint?.detectionId,
|
||||
);
|
||||
const hasSelectedRange = startIndex >= 0 && endIndex >= 0;
|
||||
const rangeItems = hasSelectedRange
|
||||
? orderedItems.slice(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex) + 1,
|
||||
)
|
||||
: [];
|
||||
const rangeDetectionIds = new Set(rangeItems.map((item) => item.id));
|
||||
const rangePath = rangeItems.map(
|
||||
(item) => [item.latitude, item.longitude] as [number, number],
|
||||
);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={initialCenter}
|
||||
zoom={15}
|
||||
scrollWheelZoom
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© OpenStreetMap contributors"
|
||||
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<FitMapToIssues items={hasSelectedRange ? rangeItems : items} />
|
||||
|
||||
{rangePath.length >= 2 ? (
|
||||
<>
|
||||
<Polyline
|
||||
positions={rangePath}
|
||||
interactive={false}
|
||||
pathOptions={{
|
||||
color: '#3b82f6',
|
||||
opacity: 0.18,
|
||||
weight: 26,
|
||||
}}
|
||||
/>
|
||||
<Polyline
|
||||
positions={rangePath}
|
||||
interactive={false}
|
||||
pathOptions={{
|
||||
color: '#2563eb',
|
||||
opacity: 0.9,
|
||||
weight: 4,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{items.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
const IssueIcon = visual.icon;
|
||||
const isSelected = selectedDetectionId === item.id;
|
||||
const isStart = startPoint?.detectionId === item.id;
|
||||
const isEnd = endPoint?.detectionId === item.id;
|
||||
const rangeLabel = isStart ? 'Start' : isEnd ? 'End' : null;
|
||||
const rangeColor = isStart
|
||||
? '#16a34a'
|
||||
: isEnd
|
||||
? '#dc2626'
|
||||
: visual.boundingBoxColor;
|
||||
const isRangeIssue = isStart || isEnd;
|
||||
const isInsideRange = rangeDetectionIds.has(item.id);
|
||||
const displayName =
|
||||
displayNames.get(item.class_name) ??
|
||||
item.display_name ??
|
||||
item.class_name.replaceAll('_', ' ');
|
||||
return (
|
||||
<CircleMarker
|
||||
key={item.id}
|
||||
center={[item.latitude, item.longitude]}
|
||||
renderer={markerRenderer}
|
||||
radius={isSelected || isRangeIssue ? 11 : isInsideRange ? 9 : 7}
|
||||
pathOptions={{
|
||||
color:
|
||||
isSelected || isRangeIssue
|
||||
? '#ffffff'
|
||||
: visual.boundingBoxColor,
|
||||
fillColor: rangeColor,
|
||||
fillOpacity:
|
||||
isSelected || isRangeIssue
|
||||
? 1
|
||||
: hasSelectedRange && !isInsideRange
|
||||
? 0.22
|
||||
: 0.82,
|
||||
opacity: hasSelectedRange && !isInsideRange ? 0.3 : 1,
|
||||
weight: isSelected || isRangeIssue ? 4 : 2,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onSelectDetection(item.id),
|
||||
}}
|
||||
>
|
||||
<Popup minWidth={240} maxWidth={280}>
|
||||
<div className="w-60">
|
||||
<div className="flex items-start gap-3 border-b pb-3 pr-4">
|
||||
<span
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${visual.boundingBoxColor}1f`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="!m-0 truncate text-sm font-semibold leading-5">
|
||||
{displayName}
|
||||
</p>
|
||||
<p className="!m-0 text-xs leading-4 text-muted-foreground">
|
||||
Detection #{item.id}
|
||||
</p>
|
||||
</div>
|
||||
{rangeLabel ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-semibold">
|
||||
<span
|
||||
className="size-3 rounded-full"
|
||||
style={{ backgroundColor: rangeColor }}
|
||||
/>
|
||||
{rangeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="!m-0 space-y-2 py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Latitude</dt>
|
||||
<dd className="!m-0 font-mono text-xs tabular-nums">
|
||||
{item.latitude.toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Longitude</dt>
|
||||
<dd className="!m-0 font-mono text-xs tabular-nums">
|
||||
{item.longitude.toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Popup>
|
||||
{rangeLabel ? (
|
||||
<Tooltip
|
||||
permanent
|
||||
direction="top"
|
||||
offset={[0, -10]}
|
||||
opacity={1}
|
||||
className="range-point-label"
|
||||
>
|
||||
<span style={{ color: rangeColor }}>{rangeLabel}</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="size-full animate-pulse bg-muted" />,
|
||||
},
|
||||
);
|
||||
|
||||
export function AssignmentIssuesMap({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onRetry,
|
||||
}: AssignmentIssuesMapProps) {
|
||||
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const validItems = useMemo(
|
||||
() =>
|
||||
(data?.items ?? []).flatMap((item, index) => {
|
||||
const mapItem = toMapItem(item, index);
|
||||
return mapItem ? [mapItem] : [];
|
||||
}),
|
||||
[data?.items],
|
||||
);
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Map(
|
||||
validItems.map((item) => [
|
||||
item.class_name,
|
||||
{
|
||||
class_name: item.class_name,
|
||||
display_name: item.display_name,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
),
|
||||
[validItems],
|
||||
);
|
||||
const pageItemCount = data?.items.length ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedDetectionId !== null &&
|
||||
!validItems.some((item) => item.id === selectedDetectionId)
|
||||
) {
|
||||
setSelectedDetectionId(null);
|
||||
}
|
||||
}, [selectedDetectionId, validItems]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-secondary p-2">
|
||||
<MapPinned className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Issues on Map</CardTitle>
|
||||
<CardDescription>
|
||||
Select Start and End in the table to preview the covered range.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{data ? (
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{validItems.length} mapped
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="h-[440px] animate-pulse bg-muted" />
|
||||
) : isError ? (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<AlertTriangle className="size-7 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium">Unable to load issue locations.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Check the connection and try again.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={onRetry}>
|
||||
<RefreshCw />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : validItems.length === 0 ? (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
|
||||
<MapPinned className="size-7 text-muted-foreground" />
|
||||
<p className="mt-3 font-medium">No issue locations available.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Detections without valid coordinates cannot be placed on the map.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[440px] min-w-0 bg-muted">
|
||||
<ClientAssignmentIssuesMap
|
||||
items={validItems}
|
||||
classes={classes}
|
||||
selectedDetectionId={selectedDetectionId}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSelectDetection={setSelectedDetectionId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-3"
|
||||
aria-label="Map legend"
|
||||
>
|
||||
<span className="text-sm font-medium">Map legend</span>
|
||||
{classes.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.class_name}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="flex size-6 items-center justify-center rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${visual.boundingBoxColor}1f`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon className="size-3.5" />
|
||||
</span>
|
||||
<span>{item.display_name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{startPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="size-4 rounded-full border-2 border-white bg-green-600 shadow-sm" />
|
||||
<span>Start</span>
|
||||
</div>
|
||||
) : null}
|
||||
{endPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="size-4 rounded-full border-2 border-white bg-red-600 shadow-sm" />
|
||||
<span>End</span>
|
||||
</div>
|
||||
) : null}
|
||||
{startPoint && endPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="relative h-4 w-8 rounded-full bg-blue-500/20">
|
||||
<span className="absolute top-1/2 right-0 left-0 h-0.5 -translate-y-1/2 bg-blue-600" />
|
||||
</span>
|
||||
<span>Coverage area</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{validItems.length} mapped from {pageItemCount} cached{' '}
|
||||
{pageItemCount === 1 ? 'issue' : 'issues'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
|
||||
interface AssignmentRangeActionsProps {
|
||||
point: AssignmentRangePoint;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
stretch?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AssignmentRangeActions({
|
||||
point,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
stretch = false,
|
||||
className,
|
||||
}: AssignmentRangeActionsProps) {
|
||||
const isStart = point.detectionId === startPoint?.detectionId;
|
||||
const isEnd = point.detectionId === endPoint?.detectionId;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isStart ? 'secondary' : 'outline'}
|
||||
className={cn(stretch && 'flex-1')}
|
||||
disabled={isStart}
|
||||
aria-pressed={isStart}
|
||||
title={isStart ? 'Selected as start' : 'Set as start'}
|
||||
onClick={() => onSetStart(point)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isEnd ? 'secondary' : 'outline'}
|
||||
className={cn(stretch && 'flex-1')}
|
||||
disabled={startPoint === null || isStart || isEnd}
|
||||
aria-pressed={isEnd}
|
||||
title={
|
||||
startPoint === null
|
||||
? 'Select a start issue first'
|
||||
: isStart
|
||||
? 'Choose another issue for the end'
|
||||
: isEnd
|
||||
? 'Selected as end'
|
||||
: 'Set as end'
|
||||
}
|
||||
onClick={() => onSetEnd(point)}
|
||||
>
|
||||
End
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FilterX } from 'lucide-react';
|
||||
|
||||
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TicketAssignmentDetectionsResponse } from '@/types';
|
||||
|
||||
import { useTicketAssignmentDetectionsQuery } from '../../../hooks/useTicketQueries';
|
||||
import {
|
||||
formatCompactRangeCoordinate,
|
||||
formatRangeCoordinate,
|
||||
type AssignmentRangePoint,
|
||||
} from './assignmentRange';
|
||||
import { AssignmentIssuesMap } from './AssignmentIssuesMap';
|
||||
import { useIssueColumns } from './IssueColumns';
|
||||
import { IssueTable } from './IssueTable';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
interface IssueTypeOption {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ChooseIssuesSectionProps {
|
||||
ticketId: string;
|
||||
issueTypes: IssueTypeOption[];
|
||||
selectedIssueTypes: string[];
|
||||
cacheRevision: number;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSelectedIssueTypesChange: (values: string[]) => void;
|
||||
onStartPointChange: (point: AssignmentRangePoint | null) => void;
|
||||
onEndPointChange: (point: AssignmentRangePoint | null) => void;
|
||||
}
|
||||
|
||||
export function ChooseIssuesSection({
|
||||
ticketId,
|
||||
issueTypes,
|
||||
selectedIssueTypes,
|
||||
cacheRevision,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSelectedIssueTypesChange,
|
||||
onStartPointChange,
|
||||
onEndPointChange,
|
||||
}: ChooseIssuesSectionProps) {
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit, setLimit] = useState(DEFAULT_PAGE_SIZE);
|
||||
const handleSetStart = useCallback(
|
||||
(point: AssignmentRangePoint) => {
|
||||
onStartPointChange(point);
|
||||
onEndPointChange(null);
|
||||
},
|
||||
[onEndPointChange, onStartPointChange],
|
||||
);
|
||||
const handleSetEnd = useCallback(
|
||||
(point: AssignmentRangePoint) => {
|
||||
if (point.detectionId === startPoint?.detectionId) return;
|
||||
|
||||
onEndPointChange(point);
|
||||
},
|
||||
[onEndPointChange, startPoint?.detectionId],
|
||||
);
|
||||
const columns = useIssueColumns({
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart: handleSetStart,
|
||||
onSetEnd: handleSetEnd,
|
||||
});
|
||||
const issueTypeOptions = useMemo(
|
||||
() =>
|
||||
issueTypes.map((issue) => ({
|
||||
value: issue.class_name,
|
||||
label: issue.display_name,
|
||||
})),
|
||||
[issueTypes],
|
||||
);
|
||||
const handleIssueTypesChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setSkip(0);
|
||||
onSelectedIssueTypesChange(values);
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
},
|
||||
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
|
||||
);
|
||||
const hasActiveFilters =
|
||||
selectedIssueTypes.length > 0 || startPoint !== null || endPoint !== null;
|
||||
const clearFilters = useCallback(() => {
|
||||
setSkip(0);
|
||||
onSelectedIssueTypesChange([]);
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
}, [onEndPointChange, onSelectedIssueTypesChange, onStartPointChange]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
assignment_status: 'unassigned' as const,
|
||||
skip,
|
||||
limit,
|
||||
class_name:
|
||||
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
|
||||
}),
|
||||
[limit, selectedIssueTypes, skip],
|
||||
);
|
||||
const detectionsQuery = useTicketAssignmentDetectionsQuery(
|
||||
ticketId,
|
||||
queryParams,
|
||||
);
|
||||
const result = detectionsQuery.data;
|
||||
const mapCacheKey = useMemo(
|
||||
() => JSON.stringify([ticketId, selectedIssueTypes, cacheRevision]),
|
||||
[cacheRevision, selectedIssueTypes, ticketId],
|
||||
);
|
||||
const [mapCache, setMapCache] = useState<
|
||||
Record<string, TicketAssignmentDetectionsResponse>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
|
||||
setMapCache((current) => {
|
||||
const itemsById = new Map(
|
||||
(current[mapCacheKey]?.items ?? []).map((item) => [
|
||||
item.detection.id,
|
||||
item,
|
||||
]),
|
||||
);
|
||||
result.items.forEach((item) => itemsById.set(item.detection.id, item));
|
||||
|
||||
return {
|
||||
...current,
|
||||
[mapCacheKey]: {
|
||||
...result,
|
||||
skip: 0,
|
||||
limit: itemsById.size,
|
||||
items: Array.from(itemsById.values()),
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [mapCacheKey, result]);
|
||||
|
||||
const mapData = mapCache[mapCacheKey];
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<Card size="sm" aria-label="Issue filters">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<FilterX />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MultiSelectPopover
|
||||
label="Issue Types"
|
||||
options={issueTypeOptions}
|
||||
values={selectedIssueTypes}
|
||||
onValuesChange={handleIssueTypesChange}
|
||||
searchPlaceholder="Search issue types"
|
||||
emptyMessage="No issue types found."
|
||||
emptySelectionLabel="All"
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{startPoint ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 px-2.5 font-normal"
|
||||
title={formatRangeCoordinate(startPoint)}
|
||||
>
|
||||
<span className="font-medium">Start</span>
|
||||
<span className="font-mono">
|
||||
{formatCompactRangeCoordinate(startPoint)}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{endPoint ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 px-2.5 font-normal"
|
||||
title={formatRangeCoordinate(endPoint)}
|
||||
>
|
||||
<span className="font-medium">End</span>
|
||||
<span className="font-mono">
|
||||
{formatCompactRangeCoordinate(endPoint)}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<IssueTable
|
||||
columns={columns}
|
||||
issues={result?.items ?? []}
|
||||
isLoading={detectionsQuery.isLoading}
|
||||
isError={detectionsQuery.isError}
|
||||
toolbar={null}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={result?.total ?? 0}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
|
||||
<AssignmentIssuesMap
|
||||
data={mapData}
|
||||
isLoading={detectionsQuery.isLoading && !mapData}
|
||||
isError={detectionsQuery.isError && !mapData}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onRetry={() => void detectionsQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type { TicketAssignmentDetectionItem } from '@/types';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
import { AssignmentRangeActions } from './AssignmentRangeActions';
|
||||
|
||||
interface UseIssueColumnsOptions {
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
}
|
||||
|
||||
export function useIssueColumns({
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
}: UseIssueColumnsOptions): ColumnDef<TicketAssignmentDetectionItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'detection_id',
|
||||
header: 'Detection ID',
|
||||
size: 140,
|
||||
cell: ({ row }) => `#${row.original.detection.id}`,
|
||||
},
|
||||
{
|
||||
id: 'issue_type',
|
||||
header: 'Issue Type',
|
||||
size: 180,
|
||||
cell: ({ row }) => {
|
||||
const { class_name: className, display_name: displayName } =
|
||||
row.original.detection;
|
||||
const visual = getDefectVisual(className);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 gap-1.5 px-2 font-normal"
|
||||
style={{
|
||||
borderColor: `${visual.boundingBoxColor}66`,
|
||||
backgroundColor: `${visual.boundingBoxColor}14`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon />
|
||||
{displayName}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'coordinates',
|
||||
header: 'Coordinates',
|
||||
size: 220,
|
||||
cell: ({ row }) => {
|
||||
const issueId = row.original.detection.id;
|
||||
const { latitude, longitude } = row.original.location;
|
||||
|
||||
if (latitude === null || longitude === null) {
|
||||
return <span className="text-muted-foreground">Unavailable</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">
|
||||
{latitude.toFixed(6)}, {longitude.toFixed(6)}
|
||||
</p>
|
||||
{issueId === startPoint?.detectionId ? (
|
||||
<p className="text-xs font-medium text-green-600">Start</p>
|
||||
) : issueId === endPoint?.detectionId ? (
|
||||
<p className="text-xs font-medium text-red-600">End</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'range_actions',
|
||||
header: 'Set range',
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const detectionId = row.original.detection.id;
|
||||
const { latitude, longitude } = row.original.location;
|
||||
const hasValidCoordinate =
|
||||
latitude !== null &&
|
||||
longitude !== null &&
|
||||
Number.isFinite(latitude) &&
|
||||
Number.isFinite(longitude) &&
|
||||
latitude >= -90 &&
|
||||
latitude <= 90 &&
|
||||
longitude >= -180 &&
|
||||
longitude <= 180;
|
||||
|
||||
if (!hasValidCoordinate) {
|
||||
return <span className="text-muted-foreground">Unavailable</span>;
|
||||
}
|
||||
|
||||
const point: AssignmentRangePoint = {
|
||||
detectionId,
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
return (
|
||||
<AssignmentRangeActions
|
||||
point={point}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSetStart={onSetStart}
|
||||
onSetEnd={onSetEnd}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[endPoint, onSetEnd, onSetStart, startPoint],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type { TicketAssignmentDetectionItem } from '@/types';
|
||||
|
||||
interface IssueTableProps {
|
||||
columns: ColumnDef<TicketAssignmentDetectionItem>[];
|
||||
issues: TicketAssignmentDetectionItem[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
toolbar: ReactNode;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
|
||||
export function IssueTable({
|
||||
columns,
|
||||
issues,
|
||||
isLoading,
|
||||
isError,
|
||||
toolbar,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: IssueTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
title="Issue details"
|
||||
columns={columns}
|
||||
data={issues}
|
||||
isLoading={isLoading}
|
||||
toolbar={toolbar}
|
||||
emptyTitle={isError ? 'Failed to load issues.' : 'No issues found.'}
|
||||
emptyDescription={
|
||||
isError
|
||||
? 'Please try again.'
|
||||
: 'No detections match the current filters.'
|
||||
}
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface AssignmentRangePoint {
|
||||
detectionId: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function formatRangeCoordinate(point: AssignmentRangePoint) {
|
||||
return `${point.latitude.toFixed(6)}, ${point.longitude.toFixed(6)}`;
|
||||
}
|
||||
|
||||
export function formatCompactRangeCoordinate(point: AssignmentRangePoint) {
|
||||
return `${point.latitude.toFixed(4)}, ${point.longitude.toFixed(4)}`;
|
||||
}
|
||||
167
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal file
167
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { useTicketOverviewQuery } from '../../hooks/useTicketQueries';
|
||||
import type { AssignmentRangePoint } from './components/assignmentRange';
|
||||
import { AssignTicketForm } from './components/AssignTicketForm';
|
||||
import { ChooseIssuesSection } from './components/ChooseIssuesSection';
|
||||
|
||||
function AssignmentPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5" aria-label="Loading assignment workspace">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Skeleton className="h-[540px] rounded-xl" />
|
||||
<Skeleton className="h-[430px] rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TicketAssignmentPage() {
|
||||
const router = useRouter();
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const [startPoint, setStartPoint] = useState<AssignmentRangePoint | null>(
|
||||
null,
|
||||
);
|
||||
const [endPoint, setEndPoint] = useState<AssignmentRangePoint | null>(null);
|
||||
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
|
||||
const [assignmentRevision, setAssignmentRevision] = useState(0);
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const overview = overviewQuery.data;
|
||||
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
|
||||
const backToTicket = () => router.push(ticketDetailUrl);
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return <AssignmentPageSkeleton />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !overview) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Unable to open the assignment workspace.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ticket information is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back to ticket
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void overviewQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const ticketName = overview.ticket_name || 'Unnamed ticket';
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<header className="flex items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={backToTicket}
|
||||
aria-label="Back to ticket"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Assign Ticket
|
||||
</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full font-mono text-xs"
|
||||
title={ticketName}
|
||||
>
|
||||
<span className="truncate">{ticketName}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-stretch xl:overflow-hidden">
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
<ChooseIssuesSection
|
||||
ticketId={ticketId}
|
||||
issueTypes={overview.ai_result.detections_by_class}
|
||||
selectedIssueTypes={selectedIssueTypes}
|
||||
cacheRevision={assignmentRevision}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSelectedIssueTypesChange={setSelectedIssueTypes}
|
||||
onStartPointChange={setStartPoint}
|
||||
onEndPointChange={setEndPoint}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent className="flex min-h-72 flex-col items-center justify-center text-center">
|
||||
<LockKeyhole className="size-8 text-muted-foreground" />
|
||||
<p className="mt-3 font-semibold">
|
||||
Assignment access required
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
You do not have permission to assign this ticket.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={backToTicket}
|
||||
>
|
||||
Back to ticket
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<AssignTicketForm
|
||||
ticketId={ticketId}
|
||||
selectedClassNames={selectedIssueTypes}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onAssigned={() => {
|
||||
setStartPoint(null);
|
||||
setEndPoint(null);
|
||||
setAssignmentRevision((revision) => revision + 1);
|
||||
}}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { DetectionDiscardReason, DiscardDetectionPayload } from '@/types';
|
||||
|
||||
const DISCARD_REASON_OPTIONS: Array<{
|
||||
value: DetectionDiscardReason;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'not_a_defect', label: 'Not a defect' },
|
||||
{ value: 'wrong_class', label: 'Wrong classification' },
|
||||
{ value: 'duplicate', label: 'Duplicate detection' },
|
||||
{ value: 'poor_image_quality', label: 'Poor image quality' },
|
||||
{ value: 'incorrect_location', label: 'Incorrect location' },
|
||||
{ value: 'already_repaired', label: 'Already repaired' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
interface DetectionDiscardDialogProps {
|
||||
open: boolean;
|
||||
detectionLabel: string;
|
||||
isPending: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: DiscardDetectionPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export function DetectionDiscardDialog({
|
||||
open,
|
||||
detectionLabel,
|
||||
isPending,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: DetectionDiscardDialogProps) {
|
||||
const [reason, setReason] = useState<DetectionDiscardReason | ''>('');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const resetForm = () => {
|
||||
setReason('');
|
||||
setComment('');
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (isPending) return;
|
||||
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!reason || isPending) return;
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
reason,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
resetForm();
|
||||
} catch {
|
||||
// The mutation displays the request error and keeps the dialog open.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent showCloseButton={!isPending}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Discard detection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove {detectionLabel} from this ticket and record why the AI
|
||||
result is incorrect.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discard-reason">Reason</Label>
|
||||
<Select
|
||||
value={reason}
|
||||
onValueChange={(value) =>
|
||||
setReason(value as DetectionDiscardReason)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger id="discard-reason" className="w-full">
|
||||
<SelectValue placeholder="Select a reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISCARD_REASON_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="discard-comment">Comment</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Optional | {comment.length}/500
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="discard-comment"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Add details that can help improve future detections"
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={!reason || isPending}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
||||
{isPending ? 'Discarding' : 'Discard detection'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
BriefcaseBusiness,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
Images,
|
||||
ListChecks,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketActor, TicketAssignmentSummary } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
|
||||
import { TicketDetectionPreview } from './TicketDetectionPreview';
|
||||
|
||||
interface TicketAssignmentsCardProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
initialAssignments?: TicketAssignmentSummary[];
|
||||
selectedAssignmentId?: number;
|
||||
onAssignmentChange: (assignmentId: number) => void;
|
||||
}
|
||||
|
||||
function formatLabel(value: string) {
|
||||
return value
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
function AssignmentClassBadge({ className }: { className: string }) {
|
||||
const visual = getDefectVisual(className);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 gap-1.5 px-2 font-normal"
|
||||
style={{
|
||||
borderColor: `${visual.boundingBoxColor}66`,
|
||||
backgroundColor: `${visual.boundingBoxColor}14`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon />
|
||||
{formatLabel(className)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentProgress({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: TicketAssignmentSummary;
|
||||
}) {
|
||||
const total = Math.max(assignment.item_count, 0);
|
||||
const approved = Math.max(assignment.approved_detections, 0);
|
||||
const progress = total > 0 ? Math.min((approved / total) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="font-medium text-foreground">Repair progress</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{approved} of {total} approved
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={assignment.is_complete ? 100 : progress} />
|
||||
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span className="whitespace-nowrap text-emerald-600">
|
||||
{assignment.approved_detections} approved
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-amber-600">
|
||||
{assignment.pending_detections} pending
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{assignment.not_submitted_detections} not submitted
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentOverview({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: TicketAssignmentSummary;
|
||||
}) {
|
||||
const criteria = assignment.criteria;
|
||||
const classNames = criteria?.class_names?.length
|
||||
? criteria.class_names
|
||||
: assignment.defect_class
|
||||
? [assignment.defect_class]
|
||||
: [];
|
||||
const worker: TicketActor = {
|
||||
user_id: assignment.assigned_to_user_id,
|
||||
name: assignment.assigned_to_name,
|
||||
email: assignment.assigned_to_email ?? null,
|
||||
avatar_url: assignment.assigned_to_avatar_url ?? null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Assignment {assignment.id}
|
||||
</p>
|
||||
<PersonInfo person={worker} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
||||
{classNames.map((className) => (
|
||||
<AssignmentClassBadge key={className} className={className} />
|
||||
))}
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ListChecks className="size-3.5" />
|
||||
{assignment.item_count}{' '}
|
||||
{assignment.item_count === 1 ? 'issue' : 'issues'}
|
||||
</span>
|
||||
{assignment.due_at ? (
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
Due {formatDate(assignment.due_at)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AssignmentProgress assignment={assignment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketAssignmentsCard({
|
||||
ticketId,
|
||||
videoId,
|
||||
initialAssignments,
|
||||
selectedAssignmentId,
|
||||
onAssignmentChange,
|
||||
}: TicketAssignmentsCardProps) {
|
||||
const assignmentsQuery = useTicketAssignmentsQuery(
|
||||
ticketId,
|
||||
initialAssignments,
|
||||
);
|
||||
const assignments = (assignmentsQuery.data?.items ?? []).filter(
|
||||
(assignment) => assignment.item_count > 0,
|
||||
);
|
||||
const selectedAssignment =
|
||||
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
|
||||
assignments[0];
|
||||
const activeAssignmentId = selectedAssignment
|
||||
? String(selectedAssignment.id)
|
||||
: '';
|
||||
const resolvedAssignmentId = selectedAssignment?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
resolvedAssignmentId !== undefined &&
|
||||
resolvedAssignmentId !== selectedAssignmentId
|
||||
) {
|
||||
onAssignmentChange(resolvedAssignmentId);
|
||||
}
|
||||
}, [onAssignmentChange, resolvedAssignmentId, selectedAssignmentId]);
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 max-w-full">
|
||||
<CardHeader className="min-w-0 px-3 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BriefcaseBusiness className="size-5 text-primary" />
|
||||
Work Assignments
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Issues grouped by assignee, scope, and repair progress.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{assignmentsQuery.data ? (
|
||||
<Badge variant="default">
|
||||
{assignments.length}{' '}
|
||||
{assignments.length === 1 ? 'Assignment' : 'Assignments'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-3 overflow-hidden px-3 sm:px-6">
|
||||
{assignmentsQuery.isLoading ? (
|
||||
Array.from({ length: 2 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-40 animate-pulse rounded-lg bg-muted"
|
||||
/>
|
||||
))
|
||||
) : assignmentsQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Unable to load work assignments.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => void assignmentsQuery.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : selectedAssignment ? (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<nav
|
||||
aria-label="Work assignments"
|
||||
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
className="flex w-max min-w-full border-b"
|
||||
>
|
||||
{assignments.map((assignment) => {
|
||||
const assignmentId = String(assignment.id);
|
||||
const isActive = assignmentId === activeAssignmentId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={assignment.id}
|
||||
id={`assignment-tab-${assignmentId}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`assignment-panel-${assignmentId}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
'relative shrink-0 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
isActive
|
||||
? 'text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => onAssignmentChange(assignment.id)}
|
||||
>
|
||||
Assignment {assignment.id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
id={`assignment-panel-${activeAssignmentId}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssignmentOverview assignment={selectedAssignment} />
|
||||
{videoId ? (
|
||||
<Collapsible
|
||||
key={selectedAssignment.id}
|
||||
className="group mt-4 overflow-hidden rounded-lg border"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-between rounded-none px-4 py-3"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Images className="size-4 text-primary" />
|
||||
Assigned issue images
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t p-4">
|
||||
<TicketDetectionPreview
|
||||
key={`${videoId}:${selectedAssignment.id}`}
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
assignmentId={selectedAssignment.id}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium">No work assignments yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Work assignments will appear here after issues are allocated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function TicketClassContentSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5" aria-label="Loading defect class details">
|
||||
<Card>
|
||||
<CardHeader className="sm:grid-cols-[1fr_auto]">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-3 w-32 sm:col-start-2 sm:row-start-1" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div key={index} className="space-y-1">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<div className="rounded-lg border bg-muted/15 px-4 py-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="aspect-video w-full rounded-md" />
|
||||
<Skeleton className="aspect-video w-full rounded-md" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="space-y-2 rounded-lg border p-2">
|
||||
<Skeleton className="aspect-4/3 w-full rounded-lg" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-5 w-56" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="relative flex gap-4 pb-8 last:pb-0">
|
||||
{index < 2 ? (
|
||||
<span className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" />
|
||||
) : null}
|
||||
<Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Skeleton className="h-4 w-44" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-56" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { AlertTriangle, Component } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
|
||||
|
||||
interface TicketDefectClassTabsProps {
|
||||
ticketId: string;
|
||||
}
|
||||
|
||||
export function TicketDefectClassTabs({
|
||||
ticketId,
|
||||
}: TicketDefectClassTabsProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const defectClassParam = searchParams.get('defect_class');
|
||||
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
|
||||
const defectClasses = useMemo(
|
||||
() => defectClassesQuery.data?.defect_classes ?? [],
|
||||
[defectClassesQuery.data?.defect_classes],
|
||||
);
|
||||
const [selectedClass, setSelectedClass] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!defectClasses.length) return;
|
||||
|
||||
const hasUrlClass = defectClasses.some(
|
||||
(item) => item.class_name === defectClassParam,
|
||||
);
|
||||
|
||||
if (hasUrlClass) {
|
||||
setSelectedClass(defectClassParam ?? undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstClass = defectClasses[0].class_name;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set('defect_class', firstClass);
|
||||
setSelectedClass(firstClass);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
}, [defectClassParam, defectClasses, pathname, router, searchParams]);
|
||||
|
||||
const handleClassChange = (className: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
nextParams.set('defect_class', className);
|
||||
setSelectedClass(className);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
if (defectClassesQuery.isLoading) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-11 w-full animate-pulse rounded-lg bg-muted"
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={selectedClass}
|
||||
onValueChange={handleClassChange}
|
||||
orientation="vertical"
|
||||
className="w-full"
|
||||
>
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="size-5 shrink-0 text-primary" />
|
||||
<span>Issues</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="min-w-5 min-h-5 justify-center rounded-full"
|
||||
>
|
||||
{defectClasses.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
|
||||
{defectClasses.map((item) => {
|
||||
const statusConfig =
|
||||
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
|
||||
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={item.class_name}
|
||||
value={item.class_name}
|
||||
className={cn(
|
||||
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
|
||||
'data-[state=active]:bg-muted data-[state=active]:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-left font-medium">
|
||||
{item.display_name}
|
||||
</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
'shrink-0 gap-1.5 rounded-full border-0 px-2.5 py-1 text-xs font-medium',
|
||||
statusConfig.badgeClassName,
|
||||
)}
|
||||
>
|
||||
<Component className="size-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -3,33 +3,63 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||
|
||||
export function TicketDetailHeader({
|
||||
ticket,
|
||||
backHref,
|
||||
}: {
|
||||
ticket: TicketOverviewDetail;
|
||||
backHref: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const ticketLabel = ticket.ticket_name;
|
||||
const ticketName = ticket.ticket_name || 'Unnamed ticket';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1>
|
||||
<p className="text-xs ">Ticket: {ticketLabel}</p>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => router.push(backHref)}
|
||||
aria-label="Back to ticket list"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Ticket Detail
|
||||
</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full font-mono text-xs"
|
||||
title={ticketName}
|
||||
>
|
||||
<span className="truncate">{ticketName}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push('/ticket')}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to List
|
||||
</Button>
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
|
||||
<OpenRepairReviewAction
|
||||
ticketId={ticket.id}
|
||||
hasPendingReview={ticket.has_pending_review}
|
||||
pendingReviewCount={ticket.pending_review_count}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<AssignTicketAction ticketId={ticket.id} />
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
useDiscardDetectionMutation,
|
||||
useTicketDetectionsQuery,
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||
|
||||
interface TicketDetectionPreviewProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
assignmentId?: number;
|
||||
assignmentStatus?: 'unassigned';
|
||||
onDetectionCountChange?: (count: number | undefined) => void;
|
||||
}
|
||||
|
||||
const DETECTION_PAGE_SIZE = 10;
|
||||
|
||||
function DetectionMetadataBar({
|
||||
items,
|
||||
}: {
|
||||
items: { label: string; value: string | number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/40 px-4 py-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cn(
|
||||
'space-y-1',
|
||||
index === 0 && 'sm:pr-4',
|
||||
index === 1 && 'sm:px-4',
|
||||
index === 2 && 'sm:pl-4',
|
||||
)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketDetectionPreviewSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="aspect-video w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton
|
||||
className="w-full rounded-lg"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/40 px-4 py-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'space-y-1',
|
||||
index === 0 && 'sm:pr-4',
|
||||
index === 1 && 'sm:px-4',
|
||||
index === 2 && 'sm:pl-4',
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketDetectionPreview({
|
||||
ticketId,
|
||||
videoId,
|
||||
assignmentId,
|
||||
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, assignmentStatus, videoId]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
skip: pageStart,
|
||||
limit: DETECTION_PAGE_SIZE,
|
||||
assignment_id: isUnassignedPreview ? undefined : assignmentId,
|
||||
assignment_status: isUnassignedPreview
|
||||
? ('unassigned' as const)
|
||||
: undefined,
|
||||
sort: 'timestamp_asc',
|
||||
}),
|
||||
[assignmentId, isUnassignedPreview, pageStart],
|
||||
);
|
||||
|
||||
const detectionsQuery = useTicketDetectionsQuery(
|
||||
videoId ?? undefined,
|
||||
queryParams,
|
||||
{ keepPreviousPage: true },
|
||||
);
|
||||
const detectionResult = detectionsQuery.data;
|
||||
const activeDetection = detectionsQuery.isPlaceholderData
|
||||
? undefined
|
||||
: detectionResult?.items[currentIndex - pageStart];
|
||||
const confirmedDetectionCount = detectionResult?.total;
|
||||
const detectionCount = confirmedDetectionCount ?? 0;
|
||||
const activeVisual = getDefectVisual(
|
||||
activeDetection?.detection.class_name ?? '',
|
||||
);
|
||||
const ActiveIssueIcon = activeVisual.icon;
|
||||
const activeDisplayName =
|
||||
activeDetection?.detection.display_name ?? 'Detection';
|
||||
const discardMutation = useDiscardDetectionMutation(
|
||||
ticketId,
|
||||
videoId ?? undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmedDetectionCount === undefined) return;
|
||||
|
||||
if (confirmedDetectionCount === 0 && currentIndex !== 0) {
|
||||
setCurrentIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
confirmedDetectionCount > 0 &&
|
||||
currentIndex > confirmedDetectionCount - 1
|
||||
) {
|
||||
setCurrentIndex(confirmedDetectionCount - 1);
|
||||
}
|
||||
}, [confirmedDetectionCount, currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
onDetectionCountChange?.(detectionCount);
|
||||
}, [detectionCount, onDetectionCountChange]);
|
||||
|
||||
const isNavigationDisabled =
|
||||
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">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
|
||||
activeVisual.cardClassName,
|
||||
)}
|
||||
>
|
||||
<ActiveIssueIcon
|
||||
className={cn('size-5', activeVisual.colorClassName)}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3>{activeDisplayName}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setCurrentIndex((index) =>
|
||||
detectionCount === 0
|
||||
? 0
|
||||
: (index - 1 + detectionCount) % detectionCount,
|
||||
)
|
||||
}
|
||||
disabled={isNavigationDisabled}
|
||||
aria-label="Previous detection"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="min-w-16 text-center text-sm font-medium text-foreground">
|
||||
{detectionCount === 0
|
||||
? '0 of 0'
|
||||
: `${currentIndex + 1} of ${detectionCount}`}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setCurrentIndex((index) =>
|
||||
detectionCount === 0 ? 0 : (index + 1) % detectionCount,
|
||||
)
|
||||
}
|
||||
disabled={isNavigationDisabled}
|
||||
aria-label="Next detection"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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={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">
|
||||
{detectionCount > 0
|
||||
? 'The selected detection details are unavailable. Try refreshing the preview.'
|
||||
: isUnassignedPreview
|
||||
? 'No unassigned issues remain on this ticket.'
|
||||
: 'No detections are available for this issue.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<p className="text-muted-foreground">Detection Image</p>
|
||||
<div className="w-full overflow-hidden rounded-lg">
|
||||
<AnnotatedDetectionImage
|
||||
detection={activeDetection}
|
||||
videoWidth={detectionResult.video_width}
|
||||
videoHeight={detectionResult.video_height}
|
||||
aspectRatio="16 / 9"
|
||||
enableLightbox
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetectionLocationMap
|
||||
className="min-w-0"
|
||||
latitude={activeDetection.location.latitude}
|
||||
longitude={activeDetection.location.longitude}
|
||||
label={`${activeDetection.detection.display_name} - Frame ${activeDetection.frame.number}`}
|
||||
title="Location on Map"
|
||||
variant="plain"
|
||||
aspectRatio="16 / 9"
|
||||
showCoordinates={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DetectionMetadataBar
|
||||
items={[
|
||||
{
|
||||
label: 'Confidence',
|
||||
value: `${Math.round(activeDetection.detection.confidence * 100)}%`,
|
||||
},
|
||||
{
|
||||
label: 'Frame',
|
||||
value: activeDetection.frame.number,
|
||||
},
|
||||
{
|
||||
label: 'Timestamp',
|
||||
value: `${activeDetection.frame.timestamp_seconds}s`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : 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,36 +1,64 @@
|
||||
'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 (
|
||||
<div className="mt-3 rounded-lg border border-amber-300/80 bg-amber-50/90 px-3 py-2.5 dark:border-amber-500/30 dark:bg-amber-500/10">
|
||||
<div className="mt-3 rounded-lg border border-amber-300/80 bg-amber-50/90 px-3 py-2.5 dark:border-amber-500/30 dark:bg-amber-500/10">
|
||||
<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-md border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
|
||||
<Badge
|
||||
|
||||
className="rounded-xs px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
|
||||
>
|
||||
<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">
|
||||
{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 }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
const isAssigned = item.event_type === 'assigned';
|
||||
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 (
|
||||
<div className="rounded-md border border-border bg-card p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||
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 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>
|
||||
<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
|
||||
@@ -124,7 +223,7 @@ export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||
))}
|
||||
</Timeline>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
||||
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No timeline entries yet
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import type { MediaPlayerInstance } from '@vidstack/react';
|
||||
|
||||
import { useProtectedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
|
||||
import AnnotatedVideoPlayer from '@/components/video/annotatedVideoPlayer';
|
||||
import { useVideoAnnotationPlayback } from '@/components/video/useVideoAnnotationPlayback';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
|
||||
type TicketInlineAnalysisVideoProps = {
|
||||
ticket: TicketOverviewDetail;
|
||||
};
|
||||
|
||||
export function TicketInlineAnalysisVideo({
|
||||
ticket,
|
||||
}: TicketInlineAnalysisVideoProps) {
|
||||
const videoRef = useRef<MediaPlayerInstance | null>(null);
|
||||
const videoId = ticket.video_id ?? ticket.video?.id ?? undefined;
|
||||
const sourceUrl = ticket.video?.url ?? undefined;
|
||||
const fps = ticket.video_metadata?.fps ?? 0;
|
||||
const videoWidth = ticket.video_metadata?.resolution.width ?? 0;
|
||||
const videoHeight = ticket.video_metadata?.resolution.height ?? 0;
|
||||
const {
|
||||
visibleDetections,
|
||||
handleSeeked,
|
||||
handleTimeUpdate,
|
||||
handleVideoFrame,
|
||||
} = useVideoAnnotationPlayback({
|
||||
videoId,
|
||||
logs: [],
|
||||
fps,
|
||||
videoRef,
|
||||
});
|
||||
const {
|
||||
videoUrl,
|
||||
isLoading: isVideoLoading,
|
||||
isError: isVideoError,
|
||||
refetch: refetchVideo,
|
||||
} = useProtectedVideoQuery(sourceUrl);
|
||||
|
||||
if (!videoId || !sourceUrl) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-sm text-muted-foreground">
|
||||
Analysis video is unavailable for this ticket.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<AnnotatedVideoPlayer
|
||||
videoRef={videoRef}
|
||||
logs={[]}
|
||||
visibleDetections={visibleDetections}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
videoUrl={videoUrl}
|
||||
isLoading={isVideoLoading}
|
||||
isError={isVideoError}
|
||||
onRetry={() => void refetchVideo()}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onVideoFrame={handleVideoFrame}
|
||||
onSeeked={handleSeeked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
CalendarDays,
|
||||
Clock3,
|
||||
Gauge,
|
||||
LayoutDashboard,
|
||||
MapPin,
|
||||
Monitor,
|
||||
UserRound,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -21,12 +20,21 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { TicketInlineAnalysisVideo } from './TicketInlineAnalysisVideo';
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>;
|
||||
|
||||
@@ -62,18 +70,16 @@ function SummaryCard({
|
||||
value,
|
||||
colorClassName,
|
||||
cardClassName,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
colorClassName: string;
|
||||
cardClassName: string;
|
||||
icon?: IconComponent;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60',
|
||||
'rounded-lg bg-muted/40 p-4 hover:scale-105 transition-transform ease-in-out',
|
||||
cardClassName,
|
||||
)}
|
||||
>
|
||||
@@ -103,168 +109,147 @@ function VideoMetric({
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewStat({
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
valueClassName?: string;
|
||||
icon: IconComponent;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60">
|
||||
<div className="min-w-0">
|
||||
<MetaLabel>{label}</MetaLabel>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 truncate text-lg font-bold tracking-tight text-foreground',
|
||||
valueClassName,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<Icon className={cn('size-5 shrink-0 opacity-80', valueClassName)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketOverviewCard({
|
||||
ticket,
|
||||
defectClass,
|
||||
}: {
|
||||
ticket: TicketOverviewDetail;
|
||||
defectClass?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const currentIssue =
|
||||
ticket.ai_result.detections_by_class.find(
|
||||
(item) => item.class_name === defectClass,
|
||||
) ?? ticket.ai_result.detections_by_class[0];
|
||||
const currentVisual = getDefectVisual(currentIssue?.class_name ?? '');
|
||||
const minutes = Math.floor(ticket.video_metadata.duration_seconds / 60);
|
||||
const seconds = Math.floor(ticket.video_metadata.duration_seconds % 60);
|
||||
const duration = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
const [isAnalysisOpen, setIsAnalysisOpen] = useState(false);
|
||||
const videoMetadata = ticket.video_metadata;
|
||||
const duration =
|
||||
typeof videoMetadata?.duration_seconds === 'number'
|
||||
? `${Math.floor(videoMetadata.duration_seconds / 60)}:${Math.floor(
|
||||
videoMetadata.duration_seconds % 60,
|
||||
)
|
||||
.toString()
|
||||
.padStart(2, '0')}`
|
||||
: null;
|
||||
const chainageName =
|
||||
ticket.location?.chainage?.name || ticket.chainage_name || null;
|
||||
const packageName = ticket.location?.package?.name || null;
|
||||
const locationLabel =
|
||||
[chainageName, packageName].filter(Boolean).join(', ') || '—';
|
||||
[chainageName, packageName].filter(Boolean).join(', ') || null;
|
||||
const uploadedOn =
|
||||
ticket.timestamps?.created_at || ticket.ai_result.completed_at || null;
|
||||
const uploaderName = ticket.uploader?.name || null;
|
||||
const analysisVideoId = ticket.video_id || ticket.video?.id || null;
|
||||
const videoName = ticket.video?.name || null;
|
||||
const analysisTitle = videoName || ticket.ticket_name || 'Analysis Video';
|
||||
const hasVideoMetrics = Boolean(
|
||||
videoMetadata?.fps || duration || videoMetadata?.resolution?.label,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||
<div className="flex items-center gap-3 sm:pr-4">
|
||||
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
||||
<OverviewField label="Uploaded On">
|
||||
<MetaValue>{formatDate(ticket.timestamps.created_at)}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:px-4">
|
||||
<UserRound className="size-5 shrink-0 text-muted-foreground" />
|
||||
<OverviewField label="Uploaded By">
|
||||
<MetaValue>{ticket.uploader.name}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:pl-4">
|
||||
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
||||
<OverviewField label="Location">
|
||||
<MetaValue>{locationLabel}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{uploadedOn || uploaderName || locationLabel ? (
|
||||
<Card>
|
||||
<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" />
|
||||
<OverviewField label="Uploaded On">
|
||||
<MetaValue>{formatDate(uploadedOn)}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
) : null}
|
||||
{uploaderName ? (
|
||||
<div className="flex items-center gap-3 sm:px-4">
|
||||
<UserRound className="size-5 shrink-0 text-muted-foreground" />
|
||||
<OverviewField label="Uploaded By">
|
||||
<MetaValue>{uploaderName}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
) : null}
|
||||
{locationLabel ? (
|
||||
<div className="flex items-center gap-3 sm:pl-4">
|
||||
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
||||
<OverviewField label="Location">
|
||||
<MetaValue>{locationLabel}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" />
|
||||
AI Detection Summary
|
||||
</CardTitle>
|
||||
{ticket.video_id ? (
|
||||
<CardAction>
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-primary hover:bg-primary/10 hover:text-primary"
|
||||
onClick={() => router.push(`/results/${ticket.video_id}`)}
|
||||
>
|
||||
View Analysis
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<SummaryCard
|
||||
label="Total Detections"
|
||||
value={ticket.ai_result.detection_count}
|
||||
colorClassName="text-violet-600"
|
||||
cardClassName="bg-violet-500/8"
|
||||
/>
|
||||
{ticket.ai_result.detections_by_class.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
return (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" />
|
||||
AI Detection Summary
|
||||
</CardTitle>
|
||||
{analysisVideoId ? (
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setIsAnalysisOpen(true)}
|
||||
>
|
||||
<Play className="mr-2 size-4" />
|
||||
View Video
|
||||
</Button>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<SummaryCard
|
||||
key={item.class_name}
|
||||
label={item.display_name}
|
||||
value={item.count}
|
||||
icon={visual.icon}
|
||||
colorClassName={visual.colorClassName}
|
||||
cardClassName={visual.cardClassName}
|
||||
label="Total Detections"
|
||||
value={ticket.ai_result.detection_count}
|
||||
colorClassName="text-violet-600"
|
||||
cardClassName="bg-violet-500/8"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
||||
<VideoMetric
|
||||
icon={Gauge}
|
||||
label={`${ticket.video_metadata.fps} FPS`}
|
||||
/>
|
||||
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
||||
<VideoMetric
|
||||
icon={Monitor}
|
||||
label={`${ticket.video_metadata.resolution.label} Resolution`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{ticket.ai_result.detections_by_class.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
return (
|
||||
<SummaryCard
|
||||
key={item.class_name}
|
||||
label={item.display_name}
|
||||
value={item.count}
|
||||
colorClassName={visual.colorClassName}
|
||||
cardClassName={visual.cardClassName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasVideoMetrics ? (
|
||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
||||
{videoMetadata?.fps ? (
|
||||
<VideoMetric
|
||||
icon={Gauge}
|
||||
label={`${videoMetadata.fps} FPS`}
|
||||
/>
|
||||
) : null}
|
||||
{duration ? (
|
||||
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
||||
) : null}
|
||||
{videoMetadata?.resolution?.label ? (
|
||||
<VideoMetric
|
||||
icon={Monitor}
|
||||
label={`${videoMetadata.resolution.label} Resolution`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LayoutDashboard className="size-5 text-primary" />
|
||||
Ticket Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
<OverviewStat
|
||||
icon={currentVisual.icon}
|
||||
label="Current Issue"
|
||||
value={currentIssue?.display_name ?? '-'}
|
||||
valueClassName={currentVisual.colorClassName}
|
||||
/>
|
||||
<OverviewStat
|
||||
icon={Activity}
|
||||
valueClassName="text-violet-600"
|
||||
label="Current Issue Count"
|
||||
value={currentIssue?.count ?? 0}
|
||||
/>
|
||||
<OverviewStat
|
||||
icon={LayoutDashboard}
|
||||
valueClassName="text-violet-600"
|
||||
label="Total AI Detections"
|
||||
value={ticket.ai_result.detection_count}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{analysisTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Video analysis with annotation overlay
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<TicketInlineAnalysisVideo ticket={ticket} />
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
function OverviewSkeletonField() {
|
||||
function OverviewMetaSkeletonField({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className={`flex items-center gap-3 ${className ?? ''}`}>
|
||||
<Skeleton className="size-5 shrink-0 rounded-lg" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-32 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummarySkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/40 p-4">
|
||||
<Skeleton className="h-7 w-10" />
|
||||
<Skeleton className="mt-3 h-3 w-24 max-w-full" />
|
||||
<Skeleton className="mt-2 h-3 w-16" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +31,12 @@ function OverviewSkeletonField() {
|
||||
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">
|
||||
@@ -27,15 +48,45 @@ export function TicketOverviewHeaderSkeleton() {
|
||||
|
||||
export function TicketOverviewCardSkeleton() {
|
||||
return (
|
||||
<Card aria-label="Loading ticket overview">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<OverviewSkeletonField key={index} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-5" aria-label="Loading ticket overview">
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||
<OverviewMetaSkeletonField className="sm:pr-4" />
|
||||
<OverviewMetaSkeletonField className="sm:px-4" />
|
||||
<OverviewMetaSkeletonField className="sm:pl-4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-lg" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</div>
|
||||
<CardAction>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<SummarySkeletonCard key={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<Skeleton className="size-4 rounded-lg" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
|
||||
import { RepairReportPanel } from './repair-report/RepairReportPanel';
|
||||
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
|
||||
|
||||
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const repair = ticket.repair;
|
||||
|
||||
if (!repair) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<RepairReportPanel ticket={ticket} />
|
||||
|
||||
<RepairVideoComparison ticket={ticket} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="size-5 text-primary" />
|
||||
Repair Images
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<RepairProofImagesGrid
|
||||
imageUrls={repair?.proof_image_urls ?? []}
|
||||
submittedAt={repair?.submitted_at ?? null}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import type { TicketAssignmentSummary } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||
|
||||
interface TicketStatusActionsProps {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignment?: TicketAssignmentSummary;
|
||||
}
|
||||
|
||||
function getTicketAction(ticket: TicketDetail) {
|
||||
if (ticket.assignment_status === 'unassigned') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<AssignTicketAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
function OverdueLotAlert({
|
||||
lotId,
|
||||
dueAt,
|
||||
}: {
|
||||
lotId: number;
|
||||
dueAt: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">Lot {lotId} is overdue</p>
|
||||
<p className="mt-0.5 text-xs text-destructive/80">
|
||||
The due date expired on {formatDate(dueAt)}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'assigned') {
|
||||
function getTicketAction(
|
||||
ticketId: string,
|
||||
assignment?: TicketAssignmentSummary,
|
||||
) {
|
||||
if (assignment) {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.WORK}
|
||||
fallback={<NoTicketAction />}
|
||||
fallback={
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.READ}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<RequestExtensionAction
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
dueAt={assignment.due_at}
|
||||
canRequest={false}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<SubmitRepairAction ticket={ticket} />
|
||||
<RequestExtensionAction
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
dueAt={assignment.due_at}
|
||||
canRequest={!assignment.is_complete}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<ReviewExtensionRequestAction ticket={ticket} />
|
||||
<ReviewExtensionRequestAction
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
canReview={!assignment.is_complete}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'under_review') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<ReviewRepairAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
ticket.assignment_status === 'approved' ||
|
||||
ticket.assignment_status === 'rejected'
|
||||
) {
|
||||
return <ClosedTicketAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
return <NoTicketAction />;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||
return getTicketAction(ticket);
|
||||
export function TicketStatusActions({
|
||||
ticketId,
|
||||
assignment,
|
||||
}: TicketStatusActionsProps) {
|
||||
const action = getTicketAction(ticketId, assignment);
|
||||
|
||||
return action ? (
|
||||
<div className="space-y-3">
|
||||
{assignment?.is_overdue ? (
|
||||
<OverdueLotAlert lotId={assignment.id} dueAt={assignment.due_at} />
|
||||
) : null}
|
||||
{action}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||
|
||||
function TimelineEntrySkeleton({
|
||||
index,
|
||||
isLast,
|
||||
}: {
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TimelineItem
|
||||
layout="left"
|
||||
isLast={isLast}
|
||||
opposite={
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="ml-auto h-4 w-16" />
|
||||
<Skeleton className="ml-auto h-3 w-12" />
|
||||
</div>
|
||||
}
|
||||
marker={<Skeleton className="size-4 rounded-full" />}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
index === 0
|
||||
? 'rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5'
|
||||
: 'rounded-lg border bg-card p-4'
|
||||
}
|
||||
>
|
||||
{index === 0 ? <Skeleton className="h-5 w-28 rounded-lg" /> : null}
|
||||
<Skeleton className={index === 0 ? 'mt-3 h-4 w-44' : 'h-4 w-44'} />
|
||||
{index === 0 ? (
|
||||
<Skeleton className="mt-2 h-3 w-full max-w-md" />
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-3 w-48 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-16 w-full rounded-lg" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketTimelineSkeleton() {
|
||||
return (
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<Timeline>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<TimelineEntrySkeleton
|
||||
key={index}
|
||||
index={index}
|
||||
isLast={index === 2}
|
||||
/>
|
||||
))}
|
||||
</Timeline>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,95 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { DatePicker } from '@/components/form/DatePicker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||
import type { AssignableTicketUser, TicketDetail } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedContractor, setSelectedContractor] =
|
||||
useState<AssignableTicketUser | null>(null);
|
||||
const [dueDate, setDueDate] = useState<Date>();
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticket.id);
|
||||
export function AssignTicketAction({ ticketId }: { ticketId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={Users} title="Assign to Contractor">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Contractor *</Label>
|
||||
<AssignableWorkerSelect
|
||||
value={selectedUserId}
|
||||
onValueChange={(userId, user) => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedContractor(user);
|
||||
}}
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{selectedContractor ? (
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">Contractor Details</p>
|
||||
{selectedContractor.phone_number ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Phone className="size-4 shrink-0" />
|
||||
{selectedContractor.phone_number}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="size-4 shrink-0" />
|
||||
<span className="truncate">{selectedContractor.email}</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
{selectedContractor.role.name}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label>Due Date *</Label>
|
||||
<DatePicker
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
showTimeZone={false}
|
||||
disabled={assignMutation.isPending}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Assignment Note (Optional)</Label>
|
||||
<Textarea
|
||||
value={assignNote}
|
||||
onChange={(event) => setAssignNote(event.target.value)}
|
||||
placeholder="Add any instructions or notes for the contractor..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!selectedUserId || !dueDate || assignMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedUserId || !dueDate) return;
|
||||
assignMutation.mutate({
|
||||
assigned_to_user_id: Number(selectedUserId),
|
||||
defect_class: ticket.defect_class,
|
||||
due_at: dueDate.toISOString(),
|
||||
note: assignNote || undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Send />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
|
||||
>
|
||||
<Users />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-xl 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
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"
|
||||
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,485 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Camera,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
Send,
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||
|
||||
const MAX_IMAGES = 30;
|
||||
const MAX_NOTES = 500;
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive">*</span>;
|
||||
}
|
||||
|
||||
function formatVideoDuration(seconds: number) {
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface MediaSectionHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
addLabel: string;
|
||||
onAdd: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MediaSectionHeader({
|
||||
icon,
|
||||
title,
|
||||
addLabel,
|
||||
onAdd,
|
||||
disabled = false,
|
||||
}: MediaSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{icon}</span>
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={onAdd}
|
||||
className="h-8 shrink-0 border-primary/30 text-primary hover:bg-primary/5 hover:text-primary"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaHint({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyMediaPlaceholderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function EmptyMediaPlaceholder({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: EmptyMediaPlaceholderProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/80 bg-muted/15 px-4 py-6 text-center transition-colors',
|
||||
'hover:border-primary/40 hover:bg-primary/5',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="max-w-xs text-xs text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImageThumbnailProps {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function ImageThumbnail({
|
||||
file,
|
||||
previewUrl,
|
||||
disabled = false,
|
||||
onRemove,
|
||||
}: ImageThumbnailProps) {
|
||||
return (
|
||||
<div className="group relative size-20 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoThumbnailProps {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
duration: string | null;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function VideoThumbnail({
|
||||
file,
|
||||
previewUrl,
|
||||
duration,
|
||||
disabled = false,
|
||||
onRemove,
|
||||
}: VideoThumbnailProps) {
|
||||
return (
|
||||
<div className="group relative h-20 w-36 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
||||
<video
|
||||
src={previewUrl}
|
||||
className="size-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
aria-label={file.name}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm">
|
||||
<Play className="size-3.5 fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
{duration ? (
|
||||
<span className="pointer-events-none absolute bottom-1.5 right-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
{duration}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RepairEvidenceFormProps {
|
||||
ticketId: string;
|
||||
defectClass: string;
|
||||
}
|
||||
|
||||
export function RepairEvidenceForm({
|
||||
ticketId,
|
||||
defectClass,
|
||||
}: RepairEvidenceFormProps) {
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [videoDuration, setVideoDuration] = useState<string | null>(null);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
||||
const isSubmitting = submitRepairUploadMutation.isPending;
|
||||
const canSubmit = Boolean(notes.trim() && videoFile);
|
||||
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
|
||||
|
||||
useEffect(() => {
|
||||
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
||||
setImagePreviews(urls);
|
||||
return () => {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [imageFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoFile) {
|
||||
setVideoPreview(null);
|
||||
setVideoDuration(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
setVideoDuration(null);
|
||||
|
||||
let cancelled = false;
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
if (cancelled || !Number.isFinite(video.duration)) return;
|
||||
setVideoDuration(formatVideoDuration(video.duration));
|
||||
};
|
||||
video.src = url;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [videoFile]);
|
||||
|
||||
const addImages = (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
|
||||
const remaining = MAX_IMAGES - imageFiles.length;
|
||||
if (remaining <= 0) {
|
||||
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextImages = [
|
||||
...imageFiles,
|
||||
...Array.from(files).slice(0, remaining),
|
||||
];
|
||||
setImageFiles(nextImages);
|
||||
setError(
|
||||
files.length > remaining
|
||||
? `Only ${MAX_IMAGES} images can be uploaded.`
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
const selectVideo = (files: FileList | null) => {
|
||||
setVideoFile(files?.[0] ?? null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const removeVideo = () => {
|
||||
setVideoFile(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImageFiles((current) =>
|
||||
current.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!notes.trim() || !videoFile) {
|
||||
setError('Repair notes and a video are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
await submitRepairUploadMutation.mutateAsync({
|
||||
defect_class: defectClass,
|
||||
notes: notes.trim(),
|
||||
video: videoFile,
|
||||
images: imageFiles,
|
||||
});
|
||||
setNotes('');
|
||||
setVideoFile(null);
|
||||
setImageFiles([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
const openImagePicker = () => {
|
||||
if (!isSubmitting && canAddMoreImages) {
|
||||
imageInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const openVideoPicker = () => {
|
||||
if (!isSubmitting) {
|
||||
videoInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
|
||||
<Camera className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">Instructions</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload clear photos and one video of the repaired area and provide
|
||||
relevant details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
disabled={isSubmitting || !canAddMoreImages}
|
||||
onChange={(event) => {
|
||||
addImages(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/x-msvideo,video/*"
|
||||
className="hidden"
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => {
|
||||
selectVideo(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="space-y-3">
|
||||
<MediaSectionHeader
|
||||
icon={<Camera className="size-4" />}
|
||||
title="Photos"
|
||||
addLabel="Add Photos"
|
||||
onAdd={openImagePicker}
|
||||
disabled={isSubmitting || !canAddMoreImages}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{imageFiles.length === 0 ? (
|
||||
<EmptyMediaPlaceholder
|
||||
icon={<ImageIcon className="size-5" />}
|
||||
title="No photos added yet"
|
||||
description="Tap to upload repair photos"
|
||||
disabled={isSubmitting}
|
||||
onClick={openImagePicker}
|
||||
className="min-h-[104px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-w-min gap-3">
|
||||
{imageFiles.map((file, index) => (
|
||||
<ImageThumbnail
|
||||
key={`${file.name}-${file.lastModified}-${index}`}
|
||||
file={file}
|
||||
previewUrl={imagePreviews[index]}
|
||||
disabled={isSubmitting}
|
||||
onRemove={() => removeImage(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaHint>
|
||||
You can upload up to {MAX_IMAGES} images (JPG, PNG)
|
||||
</MediaHint>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Video</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!videoFile || !videoPreview ? (
|
||||
<EmptyMediaPlaceholder
|
||||
icon={<Video className="size-5" />}
|
||||
title="No video added yet"
|
||||
description="Tap to upload a repair video"
|
||||
disabled={isSubmitting}
|
||||
onClick={openVideoPicker}
|
||||
className="min-h-[104px]"
|
||||
/>
|
||||
) : (
|
||||
<VideoThumbnail
|
||||
file={videoFile}
|
||||
previewUrl={videoPreview}
|
||||
duration={videoDuration}
|
||||
disabled={isSubmitting}
|
||||
onRemove={removeVideo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaHint>You can upload one video (MP4, MOV, AVI)</MediaHint>
|
||||
</section>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repair-notes">
|
||||
Repair Notes <RequiredMark />
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="repair-notes"
|
||||
value={notes}
|
||||
onChange={(event) => {
|
||||
setNotes(event.target.value.slice(0, MAX_NOTES));
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Describe the repair work completed, materials used, and any additional observations."
|
||||
disabled={isSubmitting}
|
||||
className="min-h-28 resize-none pb-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
|
||||
{notes.length} / {MAX_NOTES}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
Submit for Review
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Clock3 } from 'lucide-react';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||
|
||||
export function RequestExtensionAction({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
dueAt,
|
||||
canRequest,
|
||||
}: {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
dueAt: string | null;
|
||||
canRequest: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TicketActionCard
|
||||
icon={Clock3}
|
||||
title={canRequest ? 'Request Extension' : 'Extension Request History'}
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<TicketExtensionRequestPanel
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
dueAt={dueAt}
|
||||
canRequest={canRequest}
|
||||
/>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Clock3, Loader2, Send } from 'lucide-react';
|
||||
|
||||
import { DatePicker } from '@/components/form/DatePicker';
|
||||
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;
|
||||
onCancel: () => void;
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
currentDueAt: string | null;
|
||||
}
|
||||
|
||||
export function RequestExtensionForm({
|
||||
ticket,
|
||||
onCancel,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
currentDueAt,
|
||||
}: RequestExtensionFormProps) {
|
||||
const currentDueDate = ticket.worker?.due_at
|
||||
? new Date(ticket.worker.due_at)
|
||||
: undefined;
|
||||
const currentDueDate = useMemo(
|
||||
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
||||
[currentDueAt],
|
||||
);
|
||||
const earliestRequestedDate = useMemo(() => {
|
||||
const date = currentDueDate ? new Date(currentDueDate) : new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
if (currentDueDate) date.setDate(date.getDate() + 1);
|
||||
return date;
|
||||
}, [currentDueDate]);
|
||||
const latestRequestedMonth = useMemo(
|
||||
() => new Date(earliestRequestedDate.getFullYear() + 20, 11),
|
||||
[earliestRequestedDate],
|
||||
);
|
||||
const [requestedDueDate, setRequestedDueDate] = useState<Date>();
|
||||
const [reason, setReason] = useState('');
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
const requestExtensionMutation = useRequestTicketExtensionMutation(
|
||||
ticket.id,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
ticket.defect_class,
|
||||
);
|
||||
const isPending = requestExtensionMutation.isPending;
|
||||
const isRequestedDateValid = Boolean(
|
||||
@@ -39,10 +50,7 @@ export function RequestExtensionForm({
|
||||
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
|
||||
);
|
||||
const canSubmit =
|
||||
Boolean(assignmentId) &&
|
||||
isRequestedDateValid &&
|
||||
Boolean(reason.trim()) &&
|
||||
!isPending;
|
||||
isRequestedDateValid && Boolean(reason.trim()) && !isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!requestedDueDate || !canSubmit) return;
|
||||
@@ -53,7 +61,6 @@ export function RequestExtensionForm({
|
||||
});
|
||||
setRequestedDueDate(undefined);
|
||||
setReason('');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -81,12 +88,17 @@ export function RequestExtensionForm({
|
||||
<Label>
|
||||
Requested Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
<DatePickerSimple
|
||||
id="requested-due-date"
|
||||
value={requestedDueDate}
|
||||
onChange={setRequestedDueDate}
|
||||
showTimeZone={false}
|
||||
showLabel={false}
|
||||
placeholder="Select requested due date"
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
startMonth={earliestRequestedDate}
|
||||
endMonth={latestRequestedMonth}
|
||||
disabledDates={{ before: earliestRequestedDate }}
|
||||
/>
|
||||
{requestedDueDate && !isRequestedDateValid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
@@ -120,20 +132,15 @@ export function RequestExtensionForm({
|
||||
This request will be sent to your manager for approval.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
@@ -10,25 +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';
|
||||
import { NoTicketAction } from './NoTicketAction';
|
||||
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')
|
||||
@@ -77,35 +88,35 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
|
||||
}
|
||||
|
||||
function ExtensionReviewForm({
|
||||
ticket,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
request,
|
||||
requests,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
request: TicketExtensionRequest;
|
||||
requests: TicketExtensionRequest[];
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
const [selectedAction, setSelectedAction] = useState<
|
||||
'approve' | 'reject' | null
|
||||
>(null);
|
||||
const assignmentId = ticket.worker?.assignment_id as number;
|
||||
const reviewMutation = useReviewTicketExtensionMutation(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
ticket.defect_class,
|
||||
);
|
||||
const {
|
||||
note,
|
||||
setNote,
|
||||
noteError,
|
||||
selectedAction,
|
||||
isPending,
|
||||
isRejectConfirmationOpen,
|
||||
setIsRejectConfirmationOpen,
|
||||
submitDecision,
|
||||
requestRejection,
|
||||
} = useExtensionRequestDecision({ ticketId, assignmentId });
|
||||
const extensionDays = getExtensionDays(request);
|
||||
|
||||
const submitDecision = async (action: 'approve' | 'reject') => {
|
||||
setSelectedAction(action);
|
||||
try {
|
||||
await reviewMutation.mutateAsync({ action, note: note.trim() });
|
||||
} finally {
|
||||
setSelectedAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
|
||||
<TicketActionCard
|
||||
icon={CalendarClock}
|
||||
title="Extension Request Details"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Requester request={request} />
|
||||
|
||||
@@ -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,29 +216,63 @@ function ExtensionReviewForm({
|
||||
Approve Extension
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ExtensionRequestHistoryDialog requests={requests} />
|
||||
|
||||
<AlertDialog
|
||||
open={isRejectConfirmationOpen}
|
||||
onOpenChange={setIsRejectConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to reject this extension request? This
|
||||
decision cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
>
|
||||
Yes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewExtensionRequestAction({
|
||||
ticket,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
canReview,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
canReview: boolean;
|
||||
}) {
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
if (!assignmentId || extensionRequestQuery.isError) {
|
||||
return <NoTicketAction />;
|
||||
if (extensionRequestQuery.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (extensionRequestQuery.isLoading) {
|
||||
return (
|
||||
<TicketActionCard icon={Clock3} title="Extension Request">
|
||||
<TicketActionCard
|
||||
icon={Clock3}
|
||||
title="Extension Request"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
<span className="text-sm">Checking for requests...</span>
|
||||
@@ -226,7 +285,32 @@ export function ReviewExtensionRequestAction({
|
||||
? getPendingRequest(extensionRequestQuery.data)
|
||||
: null;
|
||||
|
||||
if (!pendingRequest) return <NoTicketAction />;
|
||||
const requests = extensionRequestQuery.data?.items ?? [];
|
||||
|
||||
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
|
||||
if (!pendingRequest || !canReview) {
|
||||
const latestRequest = extensionRequestQuery.data
|
||||
? getLatestRequest(extensionRequestQuery.data)
|
||||
: null;
|
||||
|
||||
if (!latestRequest) return null;
|
||||
|
||||
return (
|
||||
<TicketActionCard
|
||||
icon={CalendarClock}
|
||||
title="Extension Request"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<ExtensionRequestDetails request={latestRequest} requests={requests} />
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtensionReviewForm
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
request={pendingRequest}
|
||||
requests={requests}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ClipboardCheck, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [reviewComment, setReviewComment] = useState('');
|
||||
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
|
||||
|
||||
useEffect(() => {
|
||||
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
||||
}, [ticket]);
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Comment</Label>
|
||||
<Textarea
|
||||
value={reviewComment}
|
||||
onChange={(event) => setReviewComment(event.target.value)}
|
||||
placeholder="Repair verified"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewRepairMutation.mutate({
|
||||
defect_class: ticket.defect_class,
|
||||
action: 'approve',
|
||||
comment: reviewComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Check />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewRepairMutation.mutate({
|
||||
defect_class: ticket.defect_class,
|
||||
action: 'reject',
|
||||
comment: reviewComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
<X />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Wrench } from 'lucide-react';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
type RepairActionTab = 'repair-proof' | 'request-extension';
|
||||
|
||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={Wrench} title="Repair Proof">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as RepairActionTab)}
|
||||
className="gap-5"
|
||||
>
|
||||
<TabsList variant="line" className="grid h-auto w-full grid-cols-2 p-0">
|
||||
<TabsTrigger value="repair-proof" className="pb-3">
|
||||
Upload Repair Proof
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="request-extension" className="pb-3">
|
||||
Request Extension
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="repair-proof">
|
||||
<RepairEvidenceForm
|
||||
ticketId={ticket.id}
|
||||
defectClass={ticket.defect_class}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="request-extension">
|
||||
<TicketExtensionRequestPanel
|
||||
ticket={ticket}
|
||||
onCancelRequest={() => setActiveTab('repair-proof')}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -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('size-4 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,20 +15,15 @@ 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 TicketExtensionRequestPanelProps {
|
||||
ticket: TicketDetail;
|
||||
onCancelRequest: () => void;
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -50,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',
|
||||
@@ -85,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;
|
||||
@@ -104,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;
|
||||
@@ -130,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)}
|
||||
@@ -166,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}
|
||||
@@ -182,32 +192,36 @@ function ExtensionRequestDetails({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
<History />
|
||||
View Approval History
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<ExtensionRequestHistoryDialog requests={requests} />
|
||||
{request.status !== 'pending' && onRequestAnother ? (
|
||||
<Button type="button" className="w-full" onClick={onRequestAnother}>
|
||||
<Plus />
|
||||
Request Another Extension
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketExtensionRequestPanel({
|
||||
ticket,
|
||||
onCancelRequest,
|
||||
}: TicketExtensionRequestPanelProps) {
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
ticketId,
|
||||
assignmentId,
|
||||
dueAt,
|
||||
canRequest,
|
||||
}: {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
dueAt: string | null;
|
||||
canRequest: boolean;
|
||||
}) {
|
||||
const [isRequestFormOpen, setIsRequestFormOpen] = useState(false);
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
ticketId,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
if (!assignmentId) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No active assignment is available for this ticket.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (extensionRequestQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||
@@ -241,8 +255,50 @@ export function TicketExtensionRequestPanel({
|
||||
: null;
|
||||
|
||||
if (!latestRequest) {
|
||||
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
|
||||
if (!canRequest) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No extension request history is available for this ticket assignment.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RequestExtensionForm
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
currentDueAt={dueAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ExtensionRequestDetails request={latestRequest} />;
|
||||
if (isRequestFormOpen && latestRequest.status !== 'pending') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsRequestFormOpen(false)}
|
||||
>
|
||||
Back to latest request
|
||||
</Button>
|
||||
<RequestExtensionForm
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
currentDueAt={dueAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtensionRequestDetails
|
||||
request={latestRequest}
|
||||
requests={extensionRequestQuery.data?.items ?? []}
|
||||
onRequestAnother={
|
||||
canRequest ? () => setIsRequestFormOpen(true) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { ReportSurface } from './repairReportUtils';
|
||||
|
||||
const MAX_MEDIA_SLOTS = 5;
|
||||
|
||||
const imageBadgeStyles = [
|
||||
'bg-primary/90 text-primary-foreground',
|
||||
'bg-secondary text-secondary-foreground',
|
||||
'bg-accent text-accent-foreground',
|
||||
'bg-muted text-muted-foreground',
|
||||
'bg-primary/70 text-primary-foreground',
|
||||
] as const;
|
||||
|
||||
export function RepairProofImagesGrid({
|
||||
imageUrls,
|
||||
submittedAt,
|
||||
layout = 'row',
|
||||
}: {
|
||||
imageUrls: string[];
|
||||
submittedAt: string | null;
|
||||
layout?: 'row' | 'sidebar';
|
||||
}) {
|
||||
const imageSlots = Array.from(
|
||||
{ length: MAX_MEDIA_SLOTS },
|
||||
(_, index) => imageUrls[index] ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3',
|
||||
layout === 'sidebar'
|
||||
? 'grid-cols-2 auto-rows-fr'
|
||||
: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
|
||||
)}
|
||||
>
|
||||
{imageSlots.map((url, index) => (
|
||||
<RepairProofImageSlot
|
||||
key={index}
|
||||
url={url}
|
||||
index={index}
|
||||
submittedAt={submittedAt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairProofImageSlot({
|
||||
url,
|
||||
index,
|
||||
submittedAt,
|
||||
}: {
|
||||
url: string | null;
|
||||
index: number;
|
||||
submittedAt: string | null;
|
||||
}) {
|
||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<ReportSurface className="space-y-2 p-2">
|
||||
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/10 text-muted-foreground/70">
|
||||
<ImageIcon className="size-4" />
|
||||
</div>
|
||||
<p className="truncate px-1 text-[11px] text-muted-foreground/70">
|
||||
No image added
|
||||
</p>
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ReportSurface className="space-y-2 p-2">
|
||||
<Skeleton className="aspect-4/3 w-full rounded-lg" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !objectUrl) {
|
||||
return (
|
||||
<ReportSurface className="space-y-2 p-2">
|
||||
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed bg-muted/10 text-xs text-muted-foreground">
|
||||
Unavailable
|
||||
</div>
|
||||
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
||||
Image {index + 1}
|
||||
</p>
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportSurface className="space-y-2 p-2">
|
||||
<a
|
||||
href={objectUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group relative block overflow-hidden rounded-lg border bg-muted/20"
|
||||
>
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={`Repair proof ${index + 1}`}
|
||||
className="aspect-4/3 w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
</a>
|
||||
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
||||
{submittedAt ? formatDate(submittedAt) : `Image ${index + 1}`}
|
||||
</p>
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, MessageSquareQuote } from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { EmptyPanel } from './repairReportUtils';
|
||||
|
||||
function MetaLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<MetaLabel>{label}</MetaLabel>
|
||||
<p className="text-sm font-semibold text-foreground">{value || '-'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
|
||||
const repair = ticket.repair;
|
||||
|
||||
if (!repair) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-5 text-primary" />
|
||||
Repair Report
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyPanel
|
||||
icon={MessageSquareQuote}
|
||||
title="No repair report submitted yet"
|
||||
description="Repair details will appear here once the assigned worker submits evidence."
|
||||
className="py-10"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="sm:grid-cols-[1fr_auto]">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-5 text-primary" />
|
||||
Repair Report
|
||||
</CardTitle>
|
||||
{repair.submitted_at ? (
|
||||
<span className="text-xs text-muted-foreground italic sm:col-start-2 sm:row-start-1">
|
||||
Completed {formatDate(repair.submitted_at)}
|
||||
</span>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<MetaLabel>Technician</MetaLabel>
|
||||
<PersonInfo person={ticket.worker} />
|
||||
</div>
|
||||
<ReportField
|
||||
label="Submitted At"
|
||||
value={
|
||||
repair.submitted_at ? formatDate(repair.submitted_at) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<MetaLabel>Repair Summary</MetaLabel>
|
||||
{repair.notes?.trim() ? (
|
||||
<div className="rounded-lg border bg-muted/15 px-4 py-3">
|
||||
<p className="text-sm leading-relaxed text-foreground/90">
|
||||
{repair.notes}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessageSquareQuote}
|
||||
title="No repair notes added yet"
|
||||
className="py-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
|
||||
const originalVideoUrl = ticket.video?.url ?? null;
|
||||
const repairVideoUrl = ticket.repair?.proof_video_url ?? null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-5 text-primary" />
|
||||
Repair Verification
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ComparisonVideoItem
|
||||
tooltip={ticket.video?.name ?? 'Original inspection video'}
|
||||
videoLabel="Before"
|
||||
videoUrl={originalVideoUrl}
|
||||
meta="AI detection video"
|
||||
emptyTitle="No original video available"
|
||||
emptyDescription="The source inspection video is not attached to this ticket."
|
||||
/>
|
||||
<ComparisonVideoItem
|
||||
tooltip="Contractor repair evidence video"
|
||||
videoLabel="After"
|
||||
videoUrl={repairVideoUrl}
|
||||
meta="Contractor evidence video"
|
||||
emptyTitle={
|
||||
ticket.repair
|
||||
? 'No repair video uploaded'
|
||||
: 'No evidence submitted yet'
|
||||
}
|
||||
emptyDescription={
|
||||
ticket.repair
|
||||
? 'The assigned worker has not uploaded a repair video yet.'
|
||||
: 'Repair video will appear here once evidence is submitted.'
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonVideoItem({
|
||||
tooltip,
|
||||
videoLabel,
|
||||
videoUrl,
|
||||
meta,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
}: {
|
||||
tooltip: string;
|
||||
videoLabel: string;
|
||||
videoUrl: string | null;
|
||||
meta: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
}) {
|
||||
return (
|
||||
<div title={tooltip}>
|
||||
<ProtectedVideoPlayer
|
||||
url={videoUrl}
|
||||
label={videoLabel}
|
||||
meta={meta}
|
||||
emptyTitle={emptyTitle}
|
||||
emptyDescription={emptyDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold tracking-[0.12em] text-foreground uppercase">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportSurface({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('rounded-lg border bg-muted/15 p-3', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyPanel({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="mb-3 size-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 max-w-xs text-xs text-muted-foreground/80">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import LightGallery from 'lightgallery/react';
|
||||
import lgThumbnail from 'lightgallery/plugins/thumbnail';
|
||||
import lgZoom from 'lightgallery/plugins/zoom';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { protectedMediaService } from '@/services/api';
|
||||
|
||||
interface RepairProofGalleryProps {
|
||||
imageUrls: string[];
|
||||
maxVisibleImages?: number;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_IMAGES = 8;
|
||||
|
||||
export function RepairProofGallery({
|
||||
imageUrls,
|
||||
maxVisibleImages = MAX_VISIBLE_IMAGES,
|
||||
}: RepairProofGalleryProps) {
|
||||
const queries = useQueries({
|
||||
queries: imageUrls.map((url) => ({
|
||||
queryKey: ['protected-media', 'v2', url],
|
||||
queryFn: () => protectedMediaService.getBlob(url),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
})),
|
||||
});
|
||||
|
||||
const blobs = queries.map((query) => query.data);
|
||||
const objectUrls = useMemo(
|
||||
() => blobs.map((blob) => (blob ? URL.createObjectURL(blob) : null)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[...blobs],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
objectUrls.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
});
|
||||
},
|
||||
[objectUrls],
|
||||
);
|
||||
|
||||
if (queries.some((query) => query.isLoading)) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-lg">
|
||||
{imageUrls.slice(0, maxVisibleImages).map((url, index) => (
|
||||
<Skeleton
|
||||
key={`${url}-${index}`}
|
||||
className="aspect-square rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const images = objectUrls.flatMap((url, index) =>
|
||||
url ? [{ url, originalIndex: index }] : [],
|
||||
);
|
||||
const visibleCount = Math.min(maxVisibleImages, images.length);
|
||||
const overflowCount = images.length - visibleCount;
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||
Repair images are unavailable.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LightGallery
|
||||
elementClassNames="grid grid-cols-4 gap-1 overflow-hidden rounded-lg"
|
||||
mode="lg-fade"
|
||||
speed={400}
|
||||
plugins={[lgThumbnail, lgZoom]}
|
||||
thumbnail
|
||||
download={false}
|
||||
mobileSettings={{ controls: true, showCloseIcon: true, download: false }}
|
||||
>
|
||||
{images.map(({ url, originalIndex }, index) => {
|
||||
const isVisible = index < visibleCount;
|
||||
const isOverflowTile = overflowCount > 0 && index === visibleCount - 1;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={imageUrls[originalIndex]}
|
||||
data-src={url}
|
||||
data-sub-html={`<p>Repair evidence ${originalIndex + 1}</p>`}
|
||||
className={isVisible ? 'group relative aspect-square' : 'hidden'}
|
||||
aria-label={
|
||||
isOverflowTile
|
||||
? `View all ${images.length} repair images`
|
||||
: `View repair image ${originalIndex + 1} of ${images.length}`
|
||||
}
|
||||
>
|
||||
{/* Blob URLs cannot be handled by next/image. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt={`Repair evidence ${originalIndex + 1}`}
|
||||
className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90"
|
||||
/>
|
||||
{isOverflowTile ? (
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/55 px-2 text-center text-sm font-semibold text-white transition-colors group-hover:bg-black/65 sm:text-base">
|
||||
+{overflowCount} more
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</LightGallery>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { TicketDetailHeader } from './components/TicketDetailHeader';
|
||||
import {
|
||||
TicketOverviewCardSkeleton,
|
||||
TicketOverviewHeaderSkeleton,
|
||||
} from './components/TicketOverviewSkeleton';
|
||||
import {
|
||||
TicketClassActionsSkeleton,
|
||||
TicketClassContentSkeleton,
|
||||
} from './components/TicketClassDetailSkeleton';
|
||||
|
||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
|
||||
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
|
||||
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||
import { TicketUnassignedIssuesCard } from './components/TicketUnassignedIssuesCard';
|
||||
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||
import {
|
||||
useTicketClassDetailQuery,
|
||||
useTicketAssignmentsQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../hooks/useTicketQueries';
|
||||
|
||||
@@ -30,17 +29,34 @@ export default function TicketDetailPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const defectClass = searchParams.get('defect_class') ?? undefined;
|
||||
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
|
||||
const listParams = new URLSearchParams();
|
||||
|
||||
for (const key of ['page', 'limit', 'segment']) {
|
||||
const value = searchParams.get(key);
|
||||
if (value) listParams.set(key, value);
|
||||
}
|
||||
|
||||
const listSearch = listParams.toString();
|
||||
const ticketListHref = listSearch
|
||||
? `${ROUTES.TICKET}?${listSearch}`
|
||||
: ROUTES.TICKET;
|
||||
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
|
||||
const overview = overviewQuery.data;
|
||||
const classDetail = classDetailQuery.data;
|
||||
const isClassDetailLoading = Boolean(
|
||||
defectClass && classDetailQuery.isLoading,
|
||||
const assignmentsQuery = useTicketAssignmentsQuery(
|
||||
ticketId,
|
||||
overview?.assignments,
|
||||
);
|
||||
const assignments = (assignmentsQuery.data?.items ?? []).filter(
|
||||
(assignment) => assignment.item_count > 0,
|
||||
);
|
||||
const activeAssignment =
|
||||
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
|
||||
assignments[0];
|
||||
const activeAssignmentId = activeAssignment?.id;
|
||||
|
||||
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
|
||||
useTicketDetailEvents(ticketId, Boolean(ticketId));
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
@@ -49,7 +65,7 @@ export default function TicketDetailPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push('/ticket')}
|
||||
onClick={() => router.push(ticketListHref)}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back to Tickets
|
||||
@@ -59,34 +75,48 @@ export default function TicketDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5">
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
<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} backHref={ticketListHref} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
|
||||
<div className="space-y-5">
|
||||
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden">
|
||||
<div className="min-w-0 space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{overview ? (
|
||||
<TicketOverviewCard ticket={overview} defectClass={defectClass} />
|
||||
<TicketOverviewCard ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewCardSkeleton />
|
||||
)}
|
||||
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<>
|
||||
<TicketRepairReportCard ticket={classDetail} />
|
||||
<TicketHistoryCard ticket={classDetail} />
|
||||
</>
|
||||
{overview ? (
|
||||
<TicketAssignmentsCard
|
||||
ticketId={ticketId}
|
||||
videoId={overview.video_id ?? overview.video?.id}
|
||||
initialAssignments={overview.assignments}
|
||||
selectedAssignmentId={activeAssignmentId}
|
||||
onAssignmentChange={setSelectedAssignmentId}
|
||||
/>
|
||||
) : null}
|
||||
{overview ? (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<TicketUnassignedIssuesCard
|
||||
ticketId={ticketId}
|
||||
videoId={overview.video_id ?? overview.video?.id}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
) : null}
|
||||
<TicketHistoryCard ticketId={ticketId} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 self-start">
|
||||
<TicketDefectClassTabs ticketId={ticketId} />
|
||||
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<TicketStatusActions ticket={classDetail} />
|
||||
<div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{activeAssignment ? (
|
||||
<TicketStatusActions
|
||||
ticketId={ticketId}
|
||||
assignment={activeAssignment}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { RepairProofGallery } from '../../components/repair/RepairProofGallery';
|
||||
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
|
||||
|
||||
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
|
||||
not_submitted: 'Awaiting Repair',
|
||||
pending: 'Under Review',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
};
|
||||
|
||||
const REVIEW_STATUS_CLASS: Record<IssueReviewStatus, string> = {
|
||||
not_submitted: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||
pending: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
approved: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
rejected: 'bg-destructive/10 text-destructive',
|
||||
};
|
||||
|
||||
function MetadataItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1 px-4 first:pl-0 last:pr-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueEvidencePanelProps {
|
||||
detection: DetectionResultItem;
|
||||
issueNumber: number;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
reviewStatus: IssueReviewStatus;
|
||||
}
|
||||
|
||||
export function IssueEvidencePanel({
|
||||
detection,
|
||||
issueNumber,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
reviewStatus,
|
||||
}: IssueEvidencePanelProps) {
|
||||
const repairProof = detection.repair_proof;
|
||||
const displayName = detection.detection.display_name;
|
||||
const confidence = Math.round(detection.detection.confidence * 100);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">
|
||||
{displayName} #{issueNumber}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(REVIEW_STATUS_CLASS[reviewStatus])}
|
||||
>
|
||||
{REVIEW_STATUS_LABEL[reviewStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 divide-x sm:grid-cols-4">
|
||||
<MetadataItem
|
||||
label="Video Timestamp"
|
||||
value={formatVideoTimestamp(detection.frame.timestamp_seconds)}
|
||||
/>
|
||||
<MetadataItem label="Frame" value={detection.frame.number} />
|
||||
<MetadataItem label="Confidence" value={`${confidence}%`} />
|
||||
<MetadataItem label="Issue ID" value={detection.detection.id} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ImageIcon className="size-4 text-primary" />
|
||||
Detection Image (Before)
|
||||
</p>
|
||||
<AnnotatedDetectionImage
|
||||
detection={detection}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
aspectRatio="16 / 9"
|
||||
enableLightbox
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MapPin className="size-4 text-primary" />
|
||||
Detection Location
|
||||
</p>
|
||||
<DetectionLocationMap
|
||||
latitude={detection.location.latitude}
|
||||
longitude={detection.location.longitude}
|
||||
label={`${displayName} #${issueNumber} - Frame ${detection.frame.number}`}
|
||||
title=""
|
||||
variant="plain"
|
||||
aspectRatio="16 / 9"
|
||||
showCoordinates={false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<CalendarCheck2 className="size-4 text-primary" />
|
||||
Repair Proof (After Repair)
|
||||
</p>
|
||||
{repairProof?.submitted ? (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Smartphone />
|
||||
Uploaded from App
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{!repairProof?.submitted ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Repair proof is not submitted yet.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This issue cannot be reviewed until repair evidence is
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{repairProof.submitted_at ? (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
Uploaded from the field app on{' '}
|
||||
{formatDate(repairProof.submitted_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RepairProofGallery
|
||||
imageUrls={repairProof.proof_image_urls}
|
||||
maxVisibleImages={4}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5 rounded-lg border bg-card p-4 shadow-xs">
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
Repair Notes
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{repairProof.notes?.trim() ||
|
||||
'No repair notes were provided.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Info,
|
||||
Loader2,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
DetectionResultItem,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import type { IssueReviewStatus } from './reviewTypes';
|
||||
|
||||
type ReviewDecision = ReviewDetectionRepairProofPayload['action'];
|
||||
|
||||
interface IssueReviewActionProps {
|
||||
detection: DetectionResultItem;
|
||||
issueLabel: string;
|
||||
status: IssueReviewStatus;
|
||||
isPending: boolean;
|
||||
onSave: (payload: ReviewDetectionRepairProofPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export function IssueReviewAction({
|
||||
detection,
|
||||
issueLabel,
|
||||
status,
|
||||
isPending,
|
||||
onSave,
|
||||
}: IssueReviewActionProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const [decision, setDecision] = useState<ReviewDecision>();
|
||||
|
||||
useEffect(() => {
|
||||
setComment('');
|
||||
setDecision(undefined);
|
||||
}, [detection.detection.id]);
|
||||
|
||||
if (status === 'not_submitted') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review & Action
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-center">
|
||||
<Info className="mx-auto size-6 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">Repair proof required</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{issueLabel} is not ready for review.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'approved' || status === 'rejected') {
|
||||
const isApproved = status === 'approved';
|
||||
const repairProof = detection.repair_proof;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review Result
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border px-4 py-5 text-center',
|
||||
isApproved
|
||||
? 'border-emerald-500/20 bg-emerald-500/5'
|
||||
: 'border-destructive/20 bg-destructive/5',
|
||||
)}
|
||||
>
|
||||
{isApproved ? (
|
||||
<CheckCircle2 className="mx-auto size-8 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mx-auto size-8 text-destructive" />
|
||||
)}
|
||||
<Badge
|
||||
className={cn(
|
||||
'mt-3',
|
||||
isApproved
|
||||
? 'bg-emerald-500/10 text-emerald-600'
|
||||
: 'bg-destructive/10 text-destructive',
|
||||
)}
|
||||
>
|
||||
{isApproved ? 'Approved' : 'Rejected'}
|
||||
</Badge>
|
||||
{repairProof?.reviewed_by_name || repairProof?.reviewed_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{repairProof.reviewed_by_name
|
||||
? `By ${repairProof.reviewed_by_name}`
|
||||
: 'Reviewed'}
|
||||
{repairProof.reviewed_at
|
||||
? ` on ${formatDate(repairProof.reviewed_at)}`
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{repairProof?.review_comment ? (
|
||||
<div className="space-y-1.5 rounded-lg bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold">Review Comment</p>
|
||||
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
|
||||
{repairProof.review_comment}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const submitDecision = async (nextDecision: ReviewDecision) => {
|
||||
setDecision(nextDecision);
|
||||
|
||||
try {
|
||||
await onSave({
|
||||
action: nextDecision,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
// The mutation owns user-facing error feedback.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review & Action
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="review-comment">Comment (Optional)</Label>
|
||||
<Textarea
|
||||
id="review-comment"
|
||||
value={comment}
|
||||
maxLength={4000}
|
||||
rows={5}
|
||||
disabled={isPending}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Enter your review comments..."
|
||||
/>
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{comment.length} / 4000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Decision</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
>
|
||||
{isPending && decision === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
>
|
||||
{isPending && decision === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<X />
|
||||
)}
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { ProofStatusFilter } from './reviewTypes';
|
||||
|
||||
const PROOF_STATUS_FILTERS: Array<{
|
||||
value: ProofStatusFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
];
|
||||
|
||||
interface IssueReviewHeaderProps {
|
||||
ticketLabel: string;
|
||||
currentIndex: number;
|
||||
total: number;
|
||||
proofStatus: ProofStatusFilter;
|
||||
onBack: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onProofStatusChange: (status: ProofStatusFilter) => void;
|
||||
}
|
||||
|
||||
export function IssueReviewHeader({
|
||||
ticketLabel,
|
||||
currentIndex,
|
||||
total,
|
||||
proofStatus,
|
||||
onBack,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onProofStatusChange,
|
||||
}: IssueReviewHeaderProps) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
aria-label="Back to ticket"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<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">
|
||||
Repair Review
|
||||
</h1>
|
||||
<Badge variant="secondary">{ticketLabel}</Badge>
|
||||
</div>
|
||||
<div
|
||||
className="flex max-w-full flex-nowrap items-center gap-1.5 overflow-x-auto pt-1 pb-1"
|
||||
aria-label="Filter issues by repair proof status"
|
||||
>
|
||||
{PROOF_STATUS_FILTERS.map((filter) => {
|
||||
const isActive = proofStatus === filter.value;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => onProofStatusChange(filter.value)}
|
||||
className="h-7 shrink-0 rounded-full px-3 text-xs"
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={total === 0 || currentIndex <= 0}
|
||||
onClick={onPrevious}
|
||||
aria-label="Previous issue"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<div className="min-w-16 text-center text-sm font-semibold">
|
||||
{total === 0 ? '0 of 0' : `${currentIndex + 1} of ${total}`}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={total === 0 || currentIndex >= total - 1}
|
||||
onClick={onNext}
|
||||
aria-label="Next issue"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Hourglass,
|
||||
ListChecks,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
|
||||
import {
|
||||
formatVideoTimestamp,
|
||||
getIssueId,
|
||||
getIssueReviewStatus,
|
||||
type IssueReviewStatus,
|
||||
} from './reviewTypes';
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
IssueReviewStatus,
|
||||
{
|
||||
label: string;
|
||||
icon: typeof Hourglass;
|
||||
className: string;
|
||||
}
|
||||
> = {
|
||||
not_submitted: {
|
||||
label: 'Awaiting Repair',
|
||||
icon: Wrench,
|
||||
className: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||
},
|
||||
pending: {
|
||||
label: 'Under Review',
|
||||
icon: Hourglass,
|
||||
className: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
icon: CheckCircle2,
|
||||
className: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
icon: XCircle,
|
||||
className: 'bg-destructive/10 text-destructive',
|
||||
},
|
||||
};
|
||||
|
||||
interface IssueReviewQueueProps {
|
||||
detections: DetectionResultItem[];
|
||||
totalCount: number;
|
||||
selectedIssueId?: string;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onSelect: (issueId: string) => void;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
export function IssueReviewQueue({
|
||||
detections,
|
||||
totalCount,
|
||||
selectedIssueId,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onSelect,
|
||||
onLoadMore,
|
||||
}: IssueReviewQueueProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || !hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const root = target.closest<HTMLElement>(
|
||||
'[data-slot="scroll-area-viewport"]',
|
||||
);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) onLoadMore();
|
||||
},
|
||||
{ root, rootMargin: '160px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, onLoadMore]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden xl:h-full xl:min-h-0">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ListChecks className="size-5 text-primary" />
|
||||
Repair Issues
|
||||
<Badge variant="secondary" className="ml-auto rounded-full">
|
||||
{totalCount}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-2">
|
||||
<ScrollArea className="h-112 pr-2 xl:h-full">
|
||||
<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;
|
||||
const isSelected = issueId === selectedIssueId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={detection.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(issueId)}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'w-full rounded-lg border p-3 text-left transition-colors',
|
||||
'hover:border-primary/40 hover:bg-muted/50',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/6 shadow-sm'
|
||||
: 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{defectLabel} #{index + 1}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{defectLabel} #{index + 1}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock3 className="size-3.5" />
|
||||
{formatVideoTimestamp(
|
||||
detection.frame.timestamp_seconds,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('shrink-0 px-1.5', config.className)}
|
||||
>
|
||||
<StatusIcon />
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span aria-hidden="true">|</span>
|
||||
<span>Frame {detection.frame.number}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div ref={loadMoreRef} className="space-y-2 py-1">
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
Scroll to load more issues
|
||||
</p>
|
||||
) : detections.length > 0 ? (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
All {totalCount} issues loaded
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function IssueReviewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)_330px]">
|
||||
{[280, 600, 330].map((width) => (
|
||||
<Card key={width}>
|
||||
<CardHeader className="border-b">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-20 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { DetectionProofStatus, DetectionResultItem } from '@/types';
|
||||
|
||||
export type ProofStatusFilter =
|
||||
| 'all'
|
||||
| Extract<DetectionProofStatus, 'pending' | 'approved' | 'rejected'>;
|
||||
|
||||
export type IssueReviewStatus =
|
||||
| 'not_submitted'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected';
|
||||
|
||||
export function getIssueId(detection: DetectionResultItem) {
|
||||
return String(detection.detection.id);
|
||||
}
|
||||
|
||||
export function getIssueReviewStatus(
|
||||
detection: DetectionResultItem,
|
||||
): IssueReviewStatus {
|
||||
const repairProof = detection.repair_proof;
|
||||
if (!repairProof?.submitted) return 'not_submitted';
|
||||
|
||||
return repairProof.review_status;
|
||||
}
|
||||
|
||||
export function formatVideoTimestamp(totalSeconds: number) {
|
||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
|
||||
return `${minutes}:${remainder.toString().padStart(2, '0')}`;
|
||||
}
|
||||
317
src/app/(modules)/ticket/[ticketId]/review/page.tsx
Normal file
317
src/app/(modules)/ticket/[ticketId]/review/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
usePathname,
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from 'next/navigation';
|
||||
import { AlertTriangle, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import type { ReviewDetectionRepairProofPayload } from '@/types';
|
||||
|
||||
import {
|
||||
useReviewDetectionRepairProofMutation,
|
||||
useTicketReviewDetectionsQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { IssueEvidencePanel } from './components/IssueEvidencePanel';
|
||||
import { IssueReviewAction } from './components/IssueReviewAction';
|
||||
import { IssueReviewHeader } from './components/IssueReviewHeader';
|
||||
import { IssueReviewQueue } from './components/IssueReviewQueue';
|
||||
import { IssueReviewSkeleton } from './components/IssueReviewSkeleton';
|
||||
import {
|
||||
getIssueId,
|
||||
getIssueReviewStatus,
|
||||
type ProofStatusFilter,
|
||||
} from './components/reviewTypes';
|
||||
|
||||
const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
|
||||
'all',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
];
|
||||
|
||||
export default function TicketReviewPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const proofStatusParam = searchParams.get('proof_status');
|
||||
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
|
||||
proofStatusParam as ProofStatusFilter,
|
||||
)
|
||||
? (proofStatusParam as ProofStatusFilter)
|
||||
: 'all';
|
||||
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const ticket = overviewQuery.data;
|
||||
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
|
||||
const detectionsQuery = useTicketReviewDetectionsQuery(
|
||||
videoId,
|
||||
proofStatus === 'all' ? 'submitted' : proofStatus,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data?.pages[0];
|
||||
const detections = useMemo(
|
||||
() => detectionsQuery.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[detectionsQuery.data?.pages],
|
||||
);
|
||||
const totalCount = detectionResult?.total ?? 0;
|
||||
const selectedIssueParam = searchParams.get('issue_id') ?? undefined;
|
||||
const reviewMutation = useReviewDetectionRepairProofMutation({
|
||||
ticketId,
|
||||
videoId,
|
||||
});
|
||||
|
||||
const selectIssue = useCallback(
|
||||
(issueId: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set('issue_id', issueId);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const handleProofStatusChange = useCallback(
|
||||
(status: ProofStatusFilter) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
if (status === 'all') {
|
||||
nextParams.delete('proof_status');
|
||||
} else {
|
||||
nextParams.set('proof_status', status);
|
||||
}
|
||||
nextParams.delete('issue_id');
|
||||
const query = nextParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detections.length) return;
|
||||
|
||||
const hasSelectedIssue = detections.some(
|
||||
(detection) => getIssueId(detection) === selectedIssueParam,
|
||||
);
|
||||
if (hasSelectedIssue) return;
|
||||
|
||||
if (
|
||||
selectedIssueParam &&
|
||||
detectionsQuery.hasNextPage &&
|
||||
!detectionsQuery.isFetchingNextPage
|
||||
) {
|
||||
void detectionsQuery.fetchNextPage();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstReviewable = detections.find(
|
||||
(detection) => getIssueReviewStatus(detection) === 'pending',
|
||||
);
|
||||
selectIssue(getIssueId(firstReviewable ?? detections[0]));
|
||||
}, [detections, detectionsQuery, selectIssue, selectedIssueParam]);
|
||||
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
detections.findIndex(
|
||||
(detection) => getIssueId(detection) === selectedIssueParam,
|
||||
),
|
||||
);
|
||||
const activeDetection = detections[activeIndex];
|
||||
const activeIssueId = activeDetection
|
||||
? getIssueId(activeDetection)
|
||||
: undefined;
|
||||
const activeReviewStatus = activeDetection
|
||||
? getIssueReviewStatus(activeDetection)
|
||||
: 'not_submitted';
|
||||
|
||||
const handleSaveReview = useCallback(
|
||||
async (payload: ReviewDetectionRepairProofPayload) => {
|
||||
if (!activeDetection) return;
|
||||
|
||||
await reviewMutation.mutateAsync({
|
||||
detectionId: activeDetection.detection.id,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
[activeDetection, reviewMutation],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
if (activeIndex < detections.length - 1) {
|
||||
selectIssue(getIssueId(detections[activeIndex + 1]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detectionsQuery.hasNextPage) return;
|
||||
const nextResult = await detectionsQuery.fetchNextPage();
|
||||
const nextDetections =
|
||||
nextResult.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
const nextDetection = nextDetections[activeIndex + 1];
|
||||
if (nextDetection) selectIssue(getIssueId(nextDetection));
|
||||
}, [activeIndex, detections, detectionsQuery, selectIssue]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (detectionsQuery.hasNextPage && !detectionsQuery.isFetchingNextPage) {
|
||||
void detectionsQuery.fetchNextPage();
|
||||
}
|
||||
}, [detectionsQuery]);
|
||||
|
||||
const backToTicket = () => {
|
||||
router.push(ROUTES.TICKET_DETAIL(ticketId));
|
||||
};
|
||||
|
||||
if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) {
|
||||
return <IssueReviewSkeleton />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !ticket || !videoId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Unable to open the review workspace.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ticket or video information is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back to Ticket
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (detectionsQuery.isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void detectionsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeDetection || !detectionResult) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<IssueReviewHeader
|
||||
ticketLabel={ticket.ticket_name || ticket.id}
|
||||
currentIndex={0}
|
||||
total={0}
|
||||
proofStatus={proofStatus}
|
||||
onBack={backToTicket}
|
||||
onPrevious={() => undefined}
|
||||
onNext={() => undefined}
|
||||
onProofStatusChange={handleProofStatusChange}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<ClipboardCheck className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-semibold">No detections found.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
There are no issues available for review.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defectLabel = activeDetection.detection.display_name;
|
||||
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">
|
||||
<IssueReviewHeader
|
||||
ticketLabel={ticket.ticket_name || ticket.id}
|
||||
currentIndex={activeIndex}
|
||||
total={totalCount}
|
||||
proofStatus={proofStatus}
|
||||
onBack={backToTicket}
|
||||
onPrevious={() =>
|
||||
selectIssue(getIssueId(detections[Math.max(0, activeIndex - 1)]))
|
||||
}
|
||||
onNext={() => void handleNext()}
|
||||
onProofStatusChange={handleProofStatusChange}
|
||||
/>
|
||||
|
||||
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[280px_minmax(0,1fr)_330px] xl:items-stretch xl:overflow-hidden">
|
||||
<IssueReviewQueue
|
||||
detections={detections}
|
||||
totalCount={totalCount}
|
||||
selectedIssueId={activeIssueId}
|
||||
hasNextPage={Boolean(detectionsQuery.hasNextPage)}
|
||||
isFetchingNextPage={detectionsQuery.isFetchingNextPage}
|
||||
onSelect={selectIssue}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||
<IssueEvidencePanel
|
||||
detection={activeDetection}
|
||||
issueNumber={activeIndex + 1}
|
||||
videoWidth={detectionResult.video_width}
|
||||
videoHeight={detectionResult.video_height}
|
||||
reviewStatus={activeReviewStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<ClipboardCheck className="mx-auto size-7 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-semibold">View-only access</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Review permission is required to take action.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<IssueReviewAction
|
||||
detection={activeDetection}
|
||||
issueLabel={`${defectLabel} #${activeIndex + 1}`}
|
||||
status={activeReviewStatus}
|
||||
isPending={reviewMutation.isPending}
|
||||
onSave={handleSaveReview}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,27 +18,19 @@ function isHeartbeatEvent(event: { type?: string } | null | undefined) {
|
||||
|
||||
export function useTicketDetailEvents(
|
||||
ticketId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
enabled = true,
|
||||
): void {
|
||||
const queryClient = useQueryClient();
|
||||
const defectClassRef = useRef(defectClass);
|
||||
|
||||
useEffect(() => {
|
||||
defectClassRef.current = defectClass;
|
||||
}, [defectClass]);
|
||||
|
||||
const refetchTicket = useCallback(() => {
|
||||
if (!ticketId) return;
|
||||
|
||||
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
|
||||
if (defectClassRef.current) {
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
}, [queryClient, ticketId]);
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ticketService } from '@/services/api';
|
||||
import { detectionService, ticketService, videoService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
CloseTicketPayload,
|
||||
DiscardDetectionPayload,
|
||||
RequestTicketExtensionPayload,
|
||||
ReviewTicketExtensionPayload,
|
||||
ReviewRepairPayload,
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
TicketDetail,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
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;
|
||||
@@ -54,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 ?? ''),
|
||||
@@ -62,26 +89,211 @@ 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),
|
||||
queryKey: ticketKeys.timeline(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketTimeline(ticketId as string),
|
||||
enabled: Boolean(ticketId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
||||
export function useTicketAssignmentsQuery(
|
||||
ticketId: string | undefined,
|
||||
initialAssignments?: TicketAssignmentSummary[],
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
|
||||
queryKey: ticketKeys.assignments(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketAssignments(ticketId as string),
|
||||
enabled: Boolean(ticketId),
|
||||
initialData:
|
||||
initialAssignments !== undefined
|
||||
? {
|
||||
items: initialAssignments,
|
||||
total: initialAssignments.length,
|
||||
}
|
||||
: undefined,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoDetectionsParams,
|
||||
options: TicketDetectionsQueryOptions = {},
|
||||
) {
|
||||
const { enabled = true, keepPreviousPage = false } = options;
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 1,
|
||||
assignment_id: params.assignment_id,
|
||||
assignment_status: params.assignment_status,
|
||||
class_name: params.class_name,
|
||||
min_confidence: params.min_confidence,
|
||||
sort: params.sort ?? 'timestamp_asc',
|
||||
search: params.search,
|
||||
}),
|
||||
[
|
||||
params.assignment_id,
|
||||
params.assignment_status,
|
||||
params.class_name,
|
||||
params.limit,
|
||||
params.min_confidence,
|
||||
params.search,
|
||||
params.skip,
|
||||
params.sort,
|
||||
],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
|
||||
queryFn: () =>
|
||||
videoService.getVideoDetections(videoId as string, queryParams),
|
||||
enabled: Boolean(enabled && videoId),
|
||||
placeholderData: keepPreviousPage ? keepPreviousData : undefined,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketClassDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoDetectionsParams,
|
||||
) {
|
||||
return useTicketDetectionsQuery(videoId, params);
|
||||
}
|
||||
|
||||
export function useTicketAssignmentDetectionsQuery(
|
||||
ticketId: string | undefined,
|
||||
params: TicketAssignmentDetectionsParams,
|
||||
enabled = true,
|
||||
) {
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
assignment_status: params.assignment_status ?? 'unassigned',
|
||||
class_name: params.class_name,
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 500,
|
||||
}),
|
||||
[params.assignment_status, params.class_name, params.limit, params.skip],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.assignmentDetections(ticketId ?? '', queryParams),
|
||||
queryFn: () =>
|
||||
ticketService.getTicketAssignmentDetections(
|
||||
ticketId as string,
|
||||
queryParams,
|
||||
),
|
||||
enabled: Boolean(enabled && ticketId),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketReviewDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
proofStatus?: DetectionProofStatus,
|
||||
) {
|
||||
const pageSize = 20;
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ticketKeys.reviewDetections(videoId ?? '', proofStatus),
|
||||
queryFn: ({ pageParam }) =>
|
||||
videoService.getVideoDetections(videoId as string, {
|
||||
proof_status: proofStatus,
|
||||
sort: 'timestamp_asc',
|
||||
skip: pageParam,
|
||||
limit: pageSize,
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextSkip = lastPage.skip + lastPage.items.length;
|
||||
return nextSkip < lastPage.total ? nextSkip : undefined;
|
||||
},
|
||||
enabled: Boolean(videoId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiscardDetectionMutation(
|
||||
ticketId: string,
|
||||
videoId: string | undefined,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
detectionId,
|
||||
payload,
|
||||
}: {
|
||||
detectionId: number;
|
||||
payload: DiscardDetectionPayload;
|
||||
}) => detectionService.discardDetection(detectionId, payload),
|
||||
onSuccess: async () => {
|
||||
toast.success('Detection discarded');
|
||||
|
||||
await Promise.all([
|
||||
videoId
|
||||
? queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||
})
|
||||
: Promise.resolve(),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: () => toast.error('Failed to discard detection'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewDetectionRepairProofMutation({
|
||||
ticketId,
|
||||
videoId,
|
||||
}: {
|
||||
ticketId: string;
|
||||
videoId: string | undefined;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
detectionId,
|
||||
payload,
|
||||
}: {
|
||||
detectionId: number;
|
||||
payload: ReviewDetectionRepairProofPayload;
|
||||
}) => detectionService.reviewRepairProof(ticketId, detectionId, payload),
|
||||
onSuccess: async (_repairProof, variables) => {
|
||||
toast.success(
|
||||
variables.payload.action === 'approve'
|
||||
? 'Repair proof approved'
|
||||
: 'Repair proof rejected',
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
videoId
|
||||
? queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||
})
|
||||
: Promise.resolve(),
|
||||
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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,77 +320,38 @@ export function useAssignableTicketUsersQuery(enabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
function mergeTicketDetailResponse(
|
||||
current: TicketDetail | undefined,
|
||||
next: TicketDetail,
|
||||
) {
|
||||
if (!current) return next;
|
||||
|
||||
return {
|
||||
...current,
|
||||
...next,
|
||||
history: next.history ?? current.history,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAssignTicketMutation(ticketId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: AssignTicketPayload) =>
|
||||
ticketService.assignTicket(ticketId, payload),
|
||||
onSuccess: (ticket, payload) => {
|
||||
onSuccess: async () => {
|
||||
toast.success('Ticket assigned');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: () => toast.error('Failed to assign ticket'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitRepairMutation(ticketId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubmitRepairPayload) =>
|
||||
ticketService.submitRepair(ticketId, payload),
|
||||
onSuccess: (ticket) => {
|
||||
toast.success('Repair submitted');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.classDetail(ticketId, ticket.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to submit repair'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitRepairUploadMutation(ticketId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
||||
ticketService.submitRepairUpload(ticketId, payload),
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success('Repair evidence uploaded');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to upload repair evidence'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestTicketExtensionMutation(
|
||||
ticketId: string,
|
||||
assignmentId: number | undefined,
|
||||
defectClass: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -199,73 +372,39 @@ export function useRequestTicketExtensionMutation(
|
||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
queryKey: extensionRequestKeys.lists(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
|
||||
},
|
||||
onError: () => toast.error('Failed to request an extension'),
|
||||
});
|
||||
}
|
||||
export function useReviewTicketExtensionMutation(
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
defectClass: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: ReviewTicketExtensionPayload) =>
|
||||
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
|
||||
onSuccess: (_response, payload) => {
|
||||
toast.success(
|
||||
payload.action === 'approve'
|
||||
? 'Extension request approved'
|
||||
: 'Extension request rejected',
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to review extension request'),
|
||||
});
|
||||
}
|
||||
export function useReviewRepairMutation(ticketId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: ReviewRepairPayload) =>
|
||||
ticketService.reviewRepair(ticketId, payload),
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success(
|
||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||
);
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to review repair'),
|
||||
});
|
||||
}
|
||||
|
||||
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,16 +1,39 @@
|
||||
import type { TicketListParams } from '@/types';
|
||||
import type {
|
||||
TicketAssignmentDetectionsParams,
|
||||
TicketListParams,
|
||||
VideoDetectionsParams,
|
||||
} from '@/types';
|
||||
|
||||
export const ticketKeys = {
|
||||
all: ['tickets'] as const,
|
||||
lists: () => [...ticketKeys.all, 'list'] as const,
|
||||
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
|
||||
summaries: () => [...ticketKeys.all, 'summary'] as const,
|
||||
summary: (params: { chainage_id?: string }) =>
|
||||
[...ticketKeys.summaries(), params] as const,
|
||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||
overview: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
||||
classDetail: (ticketId: string, defectClass: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
||||
defectClasses: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'defect-classes'] 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,
|
||||
reviewDetections: (videoId: string, proofStatus?: string) =>
|
||||
[
|
||||
...ticketKeys.classDetectionLists(videoId),
|
||||
'review',
|
||||
proofStatus ?? 'all',
|
||||
] as const,
|
||||
assignmentDetectionLists: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'assignment-detections'] as const,
|
||||
assignmentDetections: (
|
||||
ticketId: string,
|
||||
params: TicketAssignmentDetectionsParams,
|
||||
) => [...ticketKeys.assignmentDetectionLists(ticketId), params] as const,
|
||||
assignments: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'assignments'] as const,
|
||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||
[
|
||||
...ticketKeys.details(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Loader2, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -89,19 +90,17 @@ export function CreateUploadDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-5xl"
|
||||
className="sm:max-w-5xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
Upload Video
|
||||
</DialogTitle>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Video</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure location and upload road inspection data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
<DialogBody className="space-y-5">
|
||||
<LocationSection
|
||||
value={session}
|
||||
onChange={setSession}
|
||||
@@ -130,13 +129,13 @@ export function CreateUploadDialog({
|
||||
portalContainer={dropdownPortalRef}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
Ticket,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface UploadActionsMenuProps {
|
||||
upload: UploadListItem;
|
||||
onViewVideo: (upload: UploadListItem) => void;
|
||||
}
|
||||
|
||||
export function UploadActionsMenu({
|
||||
upload,
|
||||
onViewVideo,
|
||||
}: UploadActionsMenuProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
<span className="sr-only">Open actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{upload.raw_video_url ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onViewVideo(upload);
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="size-4" />
|
||||
View Video
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem>
|
||||
<Eye /> View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<RotateCcw /> Retry Analysis
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!upload.ticket_id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (upload.ticket_id) {
|
||||
router.push(`/ticket/${upload.ticket_id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ticket /> Open Ticket
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2 /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ExternalLink, Ticket, UserRound } from 'lucide-react';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
interface UploadDetailsDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UploadDetailsDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UploadDetailsDialogProps) {
|
||||
const { hasPermission } = usePermissions();
|
||||
const canOpenTicket = hasPermission(PERMISSIONS.TICKET.READ);
|
||||
const details = [
|
||||
{ label: 'Project', value: upload?.project },
|
||||
{ label: 'Package', value: upload?.package },
|
||||
{ label: 'Segment', value: upload?.segment },
|
||||
{ label: 'Chainage', value: upload?.chainage },
|
||||
{ label: 'Uploaded', value: formatDate(upload?.uploaded_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preview the inspection video and review its processing details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,0.8fr)]">
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="text-sm font-medium">Video Preview</p>
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
label={upload?.video_name}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="min-w-0 space-y-4 rounded-lg border bg-muted/15 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<div className="mt-1.5">
|
||||
{upload ? (
|
||||
<UploadStatusBadge
|
||||
status={upload.status}
|
||||
progress={upload.progress}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Detections</p>
|
||||
<Badge variant="secondary" className="mt-1.5 tabular-nums">
|
||||
{upload?.total_detections ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="mb-2 text-xs text-muted-foreground">Result</p>
|
||||
{upload ? <UploadResultCell item={upload} /> : null}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-t pt-4">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
key={detail.label}
|
||||
className={
|
||||
detail.label === 'Uploaded' ? 'col-span-2' : undefined
|
||||
}
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{detail.label}
|
||||
</dt>
|
||||
<dd className="mt-1 truncate text-sm font-medium">
|
||||
{detail.value || '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex min-w-0 items-start gap-2 border-t pt-4">
|
||||
<UserRound className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{upload?.uploaded_by.name || 'Unknown uploader'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{upload?.uploaded_by.email || 'No email available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter showCloseButton>
|
||||
{upload?.ticket_id && canOpenTicket ? (
|
||||
<Button asChild>
|
||||
<Link href={ROUTES.TICKET_DETAIL(upload.ticket_id)}>
|
||||
<Ticket />
|
||||
Open Ticket
|
||||
<ExternalLink />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function UploadFileDropzone({
|
||||
handleFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className={cn(
|
||||
'group flex min-h-32 cursor-pointer flex-col justify-between rounded-md border border-dashed bg-card/60 p-4 transition-colors',
|
||||
'group flex min-h-32 cursor-pointer flex-col justify-between rounded-lg border border-dashed bg-card/60 p-4 transition-colors',
|
||||
'hover:border-primary/60 hover:bg-secondary/40',
|
||||
isDragging && 'border-primary bg-secondary/60',
|
||||
disabled && 'pointer-events-none cursor-not-allowed opacity-60',
|
||||
@@ -72,7 +72,7 @@ export function UploadFileDropzone({
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-md border bg-background">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border bg-background">
|
||||
<UploadCloud className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadActionsMenu } from './UploadActionsMenu';
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
@@ -22,9 +21,7 @@ function resolveThumbnailUrl(value: string | null) {
|
||||
return baseUrl ? `${baseUrl}${path}` : path;
|
||||
}
|
||||
|
||||
export function useUploadListColumns(
|
||||
onViewVideo: (upload: UploadListItem) => void,
|
||||
): ColumnDef<UploadListItem>[] {
|
||||
export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -39,18 +36,14 @@ export function useUploadListColumns(
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex aspect-video w-20 items-center justify-center overflow-hidden rounded-md border bg-muted bg-cover bg-center"
|
||||
className="flex aspect-video w-20 items-center justify-center overflow-hidden rounded-lg border bg-muted bg-cover bg-center"
|
||||
style={
|
||||
thumbnail
|
||||
? { backgroundImage: `url("${thumbnail}")` }
|
||||
: undefined
|
||||
}
|
||||
role={thumbnail ? 'img' : undefined}
|
||||
aria-label={
|
||||
thumbnail
|
||||
? `Thumbnail for ${row.original.video_name}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={thumbnail ? 'Upload thumbnail' : undefined}
|
||||
>
|
||||
{!thumbnail ? (
|
||||
<Video className="size-5 text-muted-foreground" />
|
||||
@@ -59,17 +52,9 @@ export function useUploadListColumns(
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'video_name',
|
||||
header: 'Video Name',
|
||||
size: 150,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.video_name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'uploaded_by',
|
||||
header: 'Uploader',
|
||||
header: 'Uploaded By',
|
||||
size: 190,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
@@ -145,24 +130,7 @@ export function useUploadListColumns(
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <UploadResultCell item={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<UploadActionsMenu
|
||||
upload={row.original}
|
||||
onViewVideo={onViewVideo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onViewVideo],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleX, TicketCheck } from 'lucide-react';
|
||||
import { CircleMinus, CircleX, TicketCheck } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { UploadListItem } from '@/types';
|
||||
@@ -30,5 +30,17 @@ export function UploadResultCell({ item }: { item: UploadListItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (item.result === 'no_defects_found') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-500">
|
||||
<CircleMinus className="size-3.5" /> No Defects Found
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.total_detections ?? 0} issues detected
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UploadTableProps {
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onUploadClick: (upload: UploadListItem) => void;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export function UploadTable({
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onUploadClick,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: UploadTableProps) {
|
||||
@@ -33,6 +35,7 @@ export function UploadTable({
|
||||
isLoading={isLoading}
|
||||
emptyTitle="No uploads found"
|
||||
emptyDescription="Upload your first road inspection video."
|
||||
onRowClick={onUploadClick}
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface UploadVideoDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UploadVideoDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UploadVideoDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-3 p-3 sm:max-w-5xl sm:p-4">
|
||||
<DialogHeader className="min-w-0 pr-8">
|
||||
<DialogTitle className="truncate text-left text-base">
|
||||
Video Preview
|
||||
</DialogTitle>
|
||||
<DialogDescription className="truncate text-xs">
|
||||
{upload?.video_name ?? 'Uploaded video'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { UploadListItem } from '@/types';
|
||||
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
||||
import { useUploadListColumns } from './components/UploadListColumns';
|
||||
import { UploadTable } from './components/UploadTable';
|
||||
import { UploadVideoDialog } from './components/UploadVideoDialog';
|
||||
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
|
||||
import { useUploadFilters } from './hooks/useUploadFilters';
|
||||
import { useUploadListEvents } from './hooks/useUploadListEvents';
|
||||
import { useUploadsQuery } from './hooks/useUploadQueries';
|
||||
@@ -20,13 +20,15 @@ export default function UploadPage() {
|
||||
const uploadsQuery = useUploadsQuery({ skip, limit });
|
||||
useUploadListEvents();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [videoUpload, setVideoUpload] = useState<UploadListItem | null>(null);
|
||||
const [selectedUpload, setSelectedUpload] = useState<UploadListItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const uploads = uploadsQuery.data?.items ?? [];
|
||||
const total = uploadsQuery.data?.total ?? 0;
|
||||
const isLoading = uploadsQuery.isLoading;
|
||||
|
||||
const columns = useUploadListColumns(setVideoUpload);
|
||||
const columns = useUploadListColumns();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,17 +52,18 @@ export default function UploadPage() {
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onUploadClick={setSelectedUpload}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||
<UploadVideoDialog
|
||||
upload={videoUpload}
|
||||
open={Boolean(videoUpload)}
|
||||
<UploadDetailsDialog
|
||||
upload={selectedUpload}
|
||||
open={Boolean(selectedUpload)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVideoUpload(null);
|
||||
if (!open) setSelectedUpload(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -69,6 +69,7 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={
|
||||
row.original.effective_status === 'active'
|
||||
? 'default'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user