feat(upload): open upload details dialog from table rows
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import type { UploadListItem } from '@/types';
|
import type { UploadListItem } from '@/types';
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
import { UploadActionsMenu } from './UploadActionsMenu';
|
|
||||||
import { UploadResultCell } from './UploadResultCell';
|
import { UploadResultCell } from './UploadResultCell';
|
||||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||||
|
|
||||||
@@ -22,9 +21,7 @@ function resolveThumbnailUrl(value: string | null) {
|
|||||||
return baseUrl ? `${baseUrl}${path}` : path;
|
return baseUrl ? `${baseUrl}${path}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUploadListColumns(
|
export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
|
||||||
onViewVideo: (upload: UploadListItem) => void,
|
|
||||||
): ColumnDef<UploadListItem>[] {
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -133,24 +130,7 @@ export function useUploadListColumns(
|
|||||||
meta: { disableTruncate: true },
|
meta: { disableTruncate: true },
|
||||||
cell: ({ row }) => <UploadResultCell item={row.original} />,
|
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],
|
[],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UploadTableProps {
|
|||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
onUploadClick: (upload: UploadListItem) => void;
|
||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ export function UploadTable({
|
|||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
|
onUploadClick,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
}: UploadTableProps) {
|
}: UploadTableProps) {
|
||||||
@@ -33,6 +35,7 @@ export function UploadTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle="No uploads found"
|
emptyTitle="No uploads found"
|
||||||
emptyDescription="Upload your first road inspection video."
|
emptyDescription="Upload your first road inspection video."
|
||||||
|
onRowClick={onUploadClick}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import type { UploadListItem } from '@/types';
|
|||||||
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
||||||
import { useUploadListColumns } from './components/UploadListColumns';
|
import { useUploadListColumns } from './components/UploadListColumns';
|
||||||
import { UploadTable } from './components/UploadTable';
|
import { UploadTable } from './components/UploadTable';
|
||||||
import { VideoPreviewDialog } from './components/VideoPreviewDialog';
|
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
|
||||||
import { useUploadFilters } from './hooks/useUploadFilters';
|
import { useUploadFilters } from './hooks/useUploadFilters';
|
||||||
import { useUploadListEvents } from './hooks/useUploadListEvents';
|
import { useUploadListEvents } from './hooks/useUploadListEvents';
|
||||||
import { useUploadsQuery } from './hooks/useUploadQueries';
|
import { useUploadsQuery } from './hooks/useUploadQueries';
|
||||||
@@ -20,13 +20,15 @@ export default function UploadPage() {
|
|||||||
const uploadsQuery = useUploadsQuery({ skip, limit });
|
const uploadsQuery = useUploadsQuery({ skip, limit });
|
||||||
useUploadListEvents();
|
useUploadListEvents();
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
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 uploads = uploadsQuery.data?.items ?? [];
|
||||||
const total = uploadsQuery.data?.total ?? 0;
|
const total = uploadsQuery.data?.total ?? 0;
|
||||||
const isLoading = uploadsQuery.isLoading;
|
const isLoading = uploadsQuery.isLoading;
|
||||||
|
|
||||||
const columns = useUploadListColumns(setVideoUpload);
|
const columns = useUploadListColumns();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -50,17 +52,18 @@ export default function UploadPage() {
|
|||||||
skip={skip}
|
skip={skip}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
total={total}
|
total={total}
|
||||||
|
onUploadClick={setSelectedUpload}
|
||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||||
<VideoPreviewDialog
|
<UploadDetailsDialog
|
||||||
upload={videoUpload}
|
upload={selectedUpload}
|
||||||
open={Boolean(videoUpload)}
|
open={Boolean(selectedUpload)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setVideoUpload(null);
|
if (!open) setSelectedUpload(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user