feat(upload): open upload details dialog from table rows

This commit is contained in:
2026-07-22 19:12:52 +05:30
parent 062e89fcba
commit 3891500cf2
6 changed files with 158 additions and 155 deletions

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
import type { UploadListItem } from '@/types';
import { 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(
() => [
{
@@ -133,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],
[],
);
}

View File

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

View File

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