51 Commits

Author SHA1 Message Date
224361f144 fix(ui): prevent card borders from being clipped 2026-07-24 15:17:04 +05:30
77b6755d05 refactor(ui): use icon fallback for sidebar user avatar 2026-07-23 10:16:34 +05:30
2f25fd49be fix(ticket): improve ticket detail page scrolling layout 2026-07-22 19:19:50 +05:30
3891500cf2 feat(upload): open upload details dialog from table rows 2026-07-22 19:12:52 +05:30
062e89fcba refactor(ui): update branding assets and clean up repair proof UI 2026-07-22 18:47:00 +05:30
725c256791 refactor(api): simplify backend proxy to api and biz paths 2026-07-22 17:07:05 +05:30
5d3dac0b21 refactor(api): add same-origin backend proxy for auth cookies 2026-07-22 16:18:17 +05:30
e077467bce refactor(ticket): remove web repair upload flow 2026-07-22 15:41:38 +05:30
e94da54aa9 feat(ticket): add issue-by-issue repair review page 2026-07-22 14:46:34 +05:30
76c87811d8 feat(tickets): add repair proof status filter for class issues 2026-07-21 19:24:40 +05:30
830883c6b7 fix(auth): prevent logout on transient API failures 2026-07-13 19:29:04 +05:30
68829dc72b feat(tickets): add detection discard workflow 2026-07-13 19:27:54 +05:30
9d7f956c3b feat(ticket): show assigned contractor details on in-progress tickets 2026-07-10 18:27:26 +05:30
0026824f84 Assign to Contractor // here in due date also change the year to 20 2026-07-10 16:17:53 +05:30
a18eb64dac feat(ticket): enable circular detection preview navigation 2026-07-10 16:12:30 +05:30
6c71d8f8a7 fix(ticket): widen extension due date picker range 2026-07-10 15:56:00 +05:30
0e2172366b refactor(ui): replace theme dropdown with toggle button 2026-07-10 13:38:17 +05:30
56180f8d18 style(ui): use pointer cursor for dropdown menu items 2026-07-10 13:25:28 +05:30
5269b29aac feat(ticket): use grid picker for extension due date 2026-07-10 12:24:44 +05:30
9fe7109faf style: widen plan and role sheet panels 2026-07-10 11:31:36 +05:30
9720546455 refactor(ticket): update ticket detail skeleton loaders 2026-07-10 10:42:40 +05:30
8727963c54 style: refine shared UI primitives and management flows 2026-07-09 23:06:25 +05:30
0e8108b875 feat: modify table design 2026-07-09 17:14:37 +05:30
87dde1c8d1 feat: sync codebase with latest radix pattern 2026-07-09 17:07:11 +05:30
a376558b2f refactor(layout): improve module header and sidebar toggle UX 2026-07-09 11:54:16 +05:30
4fe93460d9 feat(ui): use grid calendar picker for modern ui look 2026-07-09 10:31:36 +05:30
cbfb6e040b refactor(ticket): rename View Analysis button to View Video 2026-07-08 17:51:42 +05:30
7a82e17bbb feat(results): add live annotation overlay on raw video playback 2026-07-08 17:24:20 +05:30
23186ffd29 feat(ticket): add paginated class detection preview with image and map 2026-07-08 10:48:03 +05:30
fb9a654aea feat(results): replace video playback with detection image preview and map 2026-07-07 15:36:27 +05:30
d88978a830 chore: class location change 2026-07-07 12:02:29 +05:30
ba9731de86 refactor: refactor report proff section 2026-07-06 17:04:05 +05:30
c0f3a718ef feat(upload): display no-defects analysis result 2026-07-06 14:17:32 +05:30
14220445ad chore: ignore auto-generated next-env.d.ts 2026-07-04 00:57:42 +05:30
79272c7abc feat(ticket): add extension requests and improve repair proof upload UI 2026-07-04 00:56:08 +05:30
26e15360f7 feat(ticket): add due date picker and contractor details to assign flow 2026-07-03 16:07:08 +05:30
b6a0f04f1e Merge branch 'videoplayer' of https://git.convexsol.co/santasri.pachhal/visionRoad-ui into feat/mod 2026-07-03 10:36:32 +05:30
9cf4f0e795 feat(ticket): redesign overview with AI summary, location, and shared defect visuals 2026-07-03 10:30:59 +05:30
fdcfc3a8db feat: add action to open ticket action in upload 2026-07-02 15:44:26 +05:30
278b9591d7 feat: added sse for upload section 2026-07-02 14:28:28 +05:30
02adbb4c10 feat: introduce custom video player for video analysis 2026-07-02 14:23:00 +05:30
53fe4e11fe feat: change interface of analysis video log 2026-07-02 13:57:18 +05:30
2eb58856af feat(ticket): add section icons, theme-aware system timeline, and nested sidebar active routes 2026-07-02 11:33:53 +05:30
c73ba0a01f feat(ticket): redesign audit timeline with structured history events 2026-07-01 22:23:49 +05:30
1a94ff80e6 fix: scrollbar gutter shifting issue 2026-07-01 17:28:25 +05:30
b1174c8825 feat(ticket): split overview/class detail queries and add loading skeletons 2026-07-01 16:13:42 +05:30
d4a7a7c139 chore(ticket) : add uploader email to ticket list 2026-07-01 14:49:48 +05:30
57b33bd061 feat(upload): add upload status column and rename analysis status 2026-07-01 12:38:53 +05:30
81df3a9973 refactor: remove all trace of status from ticket 2026-07-01 10:14:19 +05:30
09ce57c9e0 refactor(ticket): remove ticket status from list events and use assignment status for closure 2026-06-30 19:30:44 +05:30
ea684fc9fe refactor(upload): replace upload card views with list table and addition details in table 2026-06-30 18:03:50 +05:30
185 changed files with 12388 additions and 3983 deletions

View File

@@ -1,2 +1,2 @@
# Backend API base URL (no trailing slash) # Server-only backend origin. Do not expose this as a NEXT_PUBLIC variable.
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 BACKEND_ORIGIN=http://localhost:8000

1
.gitignore vendored
View File

@@ -44,6 +44,7 @@ Thumbs.db
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts
# Test / coverage # Test / coverage
coverage/ coverage/

View File

@@ -1,16 +1,17 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york", "style": "radix-vega",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/app/globals.css", "css": "src/app/globals.css",
"baseColor": "slate", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"rtl": false,
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
@@ -18,5 +19,7 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"menuColor": "default",
"menuAccent": "subtle",
"registries": {} "registries": {}
} }

6
next-env.d.ts vendored
View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,7 +1,21 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const backendOrigin = process.env.BACKEND_ORIGIN?.replace(/\/+$/, '');
if (!backendOrigin) {
throw new Error('BACKEND_ORIGIN is required');
}
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, reactStrictMode: false,
async rewrites() {
return [
{
source: '/:namespace(api|biz)/:path*',
destination: `${backendOrigin}/:namespace/:path*`,
},
];
},
}; };
export default nextConfig; export default nextConfig;

3467
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,26 +27,35 @@
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.3", "@tanstack/react-virtual": "^3.14.3",
"@vidstack/react": "^1.15.6",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"leaflet": "^1.9.4",
"libphonenumber-js": "^1.13.7", "libphonenumber-js": "^1.13.7",
"lightgallery": "^2.9.0",
"lucide-react": "^0.548.0", "lucide-react": "^0.548.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "16.0.10", "next": "16.0.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
"react": "19.2.0", "react": "19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.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", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",
"@types/leaflet": "^1.9.21",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

5
public/color_logo.svg Normal file
View 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
View 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

View File

@@ -33,7 +33,7 @@ function ResetPasswordContent() {
</header> </header>
{!hasToken ? ( {!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. Password reset token is missing.
</div> </div>
) : ( ) : (

View 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>
);
}

View File

@@ -13,6 +13,7 @@ import { FormField, PhoneInput } from '@/components/form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -71,12 +72,12 @@ export function ClientSheet({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl"> <DialogContent className="sm:max-w-3xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>
{clientId ? 'Edit Client' : 'Create Client'} {clientId ? 'Edit Client' : 'Create Client'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
Manage company and primary contact details. Manage company and primary contact details.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -86,7 +87,7 @@ export function ClientSheet({
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col" 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"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
id="client-first-name" id="client-first-name"
@@ -240,9 +241,9 @@ export function ClientSheet({
/> />
</FormField> </FormField>
</div> </div>
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -1,10 +1,8 @@
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebar } from '@/components/app-sidebar';
import { ModeToggle } from '@/components/mode-toogle'; import { SidebarProvider } from '@/components/ui/sidebar';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
import { AuthGuard } from '@/guards'; import { AuthGuard } from '@/guards';
import React from 'react'; import React from 'react';
import { ModuleShellHeader } from './_components/module-shell-header';
const ModulesLayout = ({ const ModulesLayout = ({
children, children,
@@ -15,16 +13,11 @@ const ModulesLayout = ({
<AuthGuard> <AuthGuard>
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden"> <main className="flex flex-1 h-screen w-full flex-col overflow-hidden">
<div className="flex-1 overflow-auto"> <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="mx-auto flex min-h-full w-full max-w-380 flex-col">
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border"> <ModuleShellHeader />
<SidebarTrigger /> <div className="flex-1 p-6">{children}</div>
<ModeToggle />
<Separator orientation="vertical" className="mx-2 h-4" />
<BreadcrumbBasic />
</div>
<div className="p-6 flex-1">{children}</div>
</div> </div>
</div> </div>
</main> </main>

View File

@@ -10,6 +10,7 @@ import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -62,14 +63,14 @@ export function PackageDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <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()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>
{packageId ? 'Edit Package' : 'Create Package'} {packageId ? 'Edit Package' : 'Create Package'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
Manage package details and optional chainage range. Manage package details and optional chainage range.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -79,7 +80,7 @@ export function PackageDialog({
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col" 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"> <section className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3> <h3 className="text-sm font-semibold">Basic details</h3>
@@ -173,9 +174,9 @@ export function PackageDialog({
</FormField> </FormField>
</div> </div>
</section> </section>
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
import { Package as PackageIcon, Plus } from 'lucide-react'; import { Package as PackageIcon, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; 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 { Button } from '@/components/ui/button';
import type { Package } from '@/types'; import type { Package } from '@/types';
@@ -20,6 +30,7 @@ import {
export default function PackagePage() { export default function PackagePage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
const { skip, setSkip, limit, setLimit } = usePackageFilters(); const { skip, setSkip, limit, setLimit } = usePackageFilters();
const packagesQuery = usePackagesQuery({ skip, limit }); const packagesQuery = usePackagesQuery({ skip, limit });
const projectsQuery = useProjectOptionsQuery(); const projectsQuery = useProjectOptionsQuery();
@@ -67,14 +78,25 @@ export default function PackagePage() {
const deletePackage = useCallback( const deletePackage = useCallback(
(pkg: Package) => { (pkg: Package) => {
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) { setPackageToDelete(pkg);
return;
}
deletePackageMutation.mutate(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 projects = projectsQuery.data?.items ?? [];
const packages = packagesQuery.data?.items ?? []; const packages = packagesQuery.data?.items ?? [];
const total = packagesQuery.data?.totalItems ?? 0; const total = packagesQuery.data?.totalItems ?? 0;
@@ -122,6 +144,36 @@ export default function PackagePage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} 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>
</> </>
); );
} }

View File

@@ -67,7 +67,7 @@ export function PlanSheet({
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <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"> <SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight"> <SheetTitle className="text-lg leading-none font-semibold tracking-tight">
{planId ? 'Edit Plan' : 'Create Plan'} {planId ? 'Edit Plan' : 'Create Plan'}
@@ -77,7 +77,11 @@ export function PlanSheet({
</SheetDescription> </SheetDescription>
</SheetHeader> </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="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
@@ -242,7 +246,7 @@ export function PlanSheet({
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
className="size-4 rounded border-border accent-primary" className="size-4 rounded-lg border-border accent-primary"
{...register('is_active')} {...register('is_active')}
/> />
Active Active
@@ -250,7 +254,7 @@ export function PlanSheet({
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
className="size-4 rounded border-border accent-primary" className="size-4 rounded-lg border-border accent-primary"
{...register('is_custom')} {...register('is_custom')}
/> />
Custom plan Custom plan
@@ -269,7 +273,7 @@ export function PlanSheet({
} }
> >
{isPermissionsLoading ? ( {isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground"> <div className="rounded-lg border p-6 text-muted-foreground">
Loading permissions... Loading permissions...
</div> </div>
) : ( ) : (

View File

@@ -8,6 +8,7 @@ import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -54,14 +55,14 @@ export function ProjectDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <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()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>
{projectId ? 'Edit Project' : 'Create Project'} {projectId ? 'Edit Project' : 'Create Project'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
Manage road project identity and optional coordinate boundaries. Manage road project identity and optional coordinate boundaries.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -71,7 +72,7 @@ export function ProjectDialog({
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col" 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"> <section className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3> <h3 className="text-sm font-semibold">Basic details</h3>
@@ -192,9 +193,9 @@ export function ProjectDialog({
</div> </div>
</div> </div>
</section> </section>
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
import { Layers, Plus } from 'lucide-react'; import { Layers, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; 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 { Button } from '@/components/ui/button';
import type { Project } from '@/types'; import type { Project } from '@/types';
@@ -19,6 +29,7 @@ import {
export default function ProjectPage() { export default function ProjectPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const { skip, setSkip, limit, setLimit } = useProjectFilters(); const { skip, setSkip, limit, setLimit } = useProjectFilters();
const projectsQuery = useProjectsQuery({ skip, limit }); const projectsQuery = useProjectsQuery({ skip, limit });
const deleteProjectMutation = useDeleteProjectMutation(); const deleteProjectMutation = useDeleteProjectMutation();
@@ -63,16 +74,25 @@ export default function ProjectPage() {
const deleteProject = useCallback( const deleteProject = useCallback(
(project: Project) => { (project: Project) => {
if ( setProjectToDelete(project);
!confirm(`Are you sure you want to delete project "${project.name}"?`)
) {
return;
}
deleteProjectMutation.mutate(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 columns = useProjectColumns();
const projects = projectsQuery.data?.items ?? []; const projects = projectsQuery.data?.items ?? [];
const total = projectsQuery.data?.totalItems ?? 0; const total = projectsQuery.data?.totalItems ?? 0;
@@ -117,6 +137,36 @@ export default function ProjectPage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} 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>
</> </>
); );
} }

View File

@@ -5,7 +5,7 @@ import { Skeleton } from '@/components/ui/skeleton';
function StatSkeleton() { function StatSkeleton() {
return ( return (
<div className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center"> <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="h-6 w-12" />
<Skeleton className="mt-2 h-3 w-20" /> <Skeleton className="mt-2 h-3 w-20" />
</div> </div>
@@ -14,20 +14,23 @@ function StatSkeleton() {
function DetectionLogSkeleton() { function DetectionLogSkeleton() {
return ( return (
<div className="rounded-md border bg-card p-4"> <div className="rounded-lg border bg-card p-3">
<div className="mb-3 flex items-start justify-between gap-4"> <div className="flex gap-3">
<div className="space-y-2"> <Skeleton className="size-20 shrink-0 rounded-lg" />
<Skeleton className="h-4 w-36" /> <div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-3 w-20" /> <div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3 w-10" />
</div>
</div>
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-44" />
</div> </div>
<div className="space-y-2">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3 w-10" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-44" />
</div> </div>
</div> </div>
); );
@@ -38,7 +41,7 @@ export function VideoResultsSkeleton() {
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <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"> <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"> <div className="min-w-0 space-y-2">
<Skeleton className="h-7 w-60 max-w-full" /> <Skeleton className="h-7 w-60 max-w-full" />
<Skeleton className="h-3 w-80 max-w-full" /> <Skeleton className="h-3 w-80 max-w-full" />
@@ -47,10 +50,10 @@ export function VideoResultsSkeleton() {
<Skeleton className="h-8 w-20" /> <Skeleton className="h-8 w-20" />
</div> </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="border-b p-6 pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="size-10 rounded" /> <Skeleton className="size-10 rounded-lg" />
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-5 w-44" /> <Skeleton className="h-5 w-44" />
<Skeleton className="h-3 w-64" /> <Skeleton className="h-3 w-64" />
@@ -73,7 +76,7 @@ export function VideoResultsSkeleton() {
</div> </div>
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => ( {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> </div>
</section> </section>

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { videoService } from '@/services/api'; import { videoService } from '@/services/api';
import type { VideoAnnotationFramesParams } from '@/types';
import { videoKeys } from '../queries/videoKeys'; 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 * Download the annotated video through the authenticated axios client and
* expose a local object URL that the native `<video>` element can play. * 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 * directly is what fixes the 401. The browser cannot attach the Bearer token to
* a media request, but axios can. * a media request, but axios can.
*/ */
export function useAnnotatedVideoQuery(url: string | undefined) { export function useProtectedVideoQuery(url: string | undefined) {
const query = useQuery({ const query = useQuery({
queryKey: videoKeys.annotatedVideo(url ?? ''), queryKey: videoKeys.annotatedVideo(url ?? ''),
queryFn: () => videoService.getAnnotatedVideo(url as string), queryFn: () => videoService.getProtectedVideo(url as string),
enabled: Boolean(url), enabled: Boolean(url),
staleTime: Infinity, staleTime: Infinity,
gcTime: 1000 * 60 * 30, gcTime: 1000 * 60 * 30,
@@ -59,3 +74,5 @@ export function useAnnotatedVideoQuery(url: string | undefined) {
refetch: query.refetch, refetch: query.refetch,
}; };
} }
export const useAnnotatedVideoQuery = useProtectedVideoQuery;

View File

@@ -2,6 +2,10 @@ export const videoKeys = {
all: ['videos'] as const, all: ['videos'] as const,
results: () => [...videoKeys.all, 'results'] as const, results: () => [...videoKeys.all, 'results'] as const,
result: (videoId: string) => [...videoKeys.results(), videoId] 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, annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
annotatedVideo: (url: string) => annotatedVideo: (url: string) =>
[...videoKeys.annotatedVideos(), url] as const, [...videoKeys.annotatedVideos(), url] as const,

View File

@@ -62,7 +62,7 @@ export function RoleSheet({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
side="right" 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"> <SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight"> <SheetTitle className="text-lg leading-none font-semibold tracking-tight">
@@ -73,7 +73,11 @@ export function RoleSheet({
</SheetDescription> </SheetDescription>
</SheetHeader> </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="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
@@ -125,7 +129,7 @@ export function RoleSheet({
} }
> >
{isPermissionsLoading ? ( {isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground"> <div className="rounded-lg border p-6 text-muted-foreground">
Loading permissions... Loading permissions...
</div> </div>
) : ( ) : (

View File

@@ -11,6 +11,7 @@ import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -71,14 +72,14 @@ export function SegmentDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <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()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>
{segmentId ? 'Edit Segment' : 'Create Segment'} {segmentId ? 'Edit Segment' : 'Create Segment'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
Manage segment binding, chainage values, direction, and coordinates. Manage segment binding, chainage values, direction, and coordinates.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -88,7 +89,7 @@ export function SegmentDialog({
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col" 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"> <section className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3> <h3 className="text-sm font-semibold">Basic details</h3>
@@ -300,9 +301,9 @@ export function SegmentDialog({
</div> </div>
</div> </div>
</section> </section>
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
import { Milestone, Plus } from 'lucide-react'; import { Milestone, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; 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 { Button } from '@/components/ui/button';
import type { Chainage } from '@/types'; import type { Chainage } from '@/types';
@@ -21,6 +31,7 @@ import {
export default function SegmentPage() { export default function SegmentPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
const { skip, setSkip, limit, setLimit } = useSegmentFilters(); const { skip, setSkip, limit, setLimit } = useSegmentFilters();
const segmentsQuery = useSegmentsQuery({ skip, limit }); const segmentsQuery = useSegmentsQuery({ skip, limit });
const projectsQuery = useProjectOptionsQuery(); const projectsQuery = useProjectOptionsQuery();
@@ -75,18 +86,25 @@ export default function SegmentPage() {
const deleteSegment = useCallback( const deleteSegment = useCallback(
(segment: Chainage) => { (segment: Chainage) => {
if ( setSegmentToDelete(segment);
!confirm(
`Are you sure you want to delete segment "${segment.segment_name}"?`,
)
) {
return;
}
deleteSegmentMutation.mutate(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 projects = projectsQuery.data?.items ?? [];
const allPackages = allPackagesQuery.data?.items ?? []; const allPackages = allPackagesQuery.data?.items ?? [];
const segments = segmentsQuery.data?.items ?? []; const segments = segmentsQuery.data?.items ?? [];
@@ -139,6 +157,36 @@ export default function SegmentPage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} 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>
</> </>
); );
} }

View File

@@ -13,6 +13,7 @@ import { FormField, PhoneInput, SelectPopover } from '@/components/form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -98,12 +99,12 @@ export function TenantSheet({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl"> <DialogContent className="sm:max-w-3xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>
{tenantId ? 'Edit Tenant' : 'Create Tenant'} {tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
Bind a client and subscription plan, then invite the tenant Bind a client and subscription plan, then invite the tenant
administrator. administrator.
</DialogDescription> </DialogDescription>
@@ -114,7 +115,7 @@ export function TenantSheet({
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col" 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"> <div className="space-y-1">
<p>Basic Information</p> <p>Basic Information</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
@@ -227,7 +228,7 @@ export function TenantSheet({
id="tenant-description" id="tenant-description"
placeholder="North zone operations" placeholder="North zone operations"
{...register('description')} {...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> </FormField>
</div> </div>
@@ -314,7 +315,7 @@ export function TenantSheet({
</div> </div>
</> </>
) : adminEmail ? ( ) : 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 Admin invitation details cannot be changed after tenant
creation. creation.
<p className="mt-1 text-foreground"> <p className="mt-1 text-foreground">
@@ -322,9 +323,9 @@ export function TenantSheet({
</p> </p>
</div> </div>
) : null} ) : null}
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -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>
);
}

View File

@@ -0,0 +1,216 @@
'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="outline"
disabled={isPending}
onClick={() => void submitDecision('approve')}
className={cn(
decision === 'approve' &&
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
)}
>
{isPending && decision === 'approve' ? (
<Loader2 className="animate-spin" />
) : (
<Check />
)}
Approve
</Button>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => void submitDecision('reject')}
className={cn(
decision === 'reject' &&
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
)}
>
{isPending && decision === 'reject' ? (
<Loader2 className="animate-spin" />
) : (
<X />
)}
Reject
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,113 @@
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;
defectLabel: string;
currentIndex: number;
total: number;
proofStatus: ProofStatusFilter;
onBack: () => void;
onPrevious: () => void;
onNext: () => void;
onProofStatusChange: (status: ProofStatusFilter) => void;
}
export function IssueReviewHeader({
ticketLabel,
defectLabel,
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">
{defectLabel} 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>
);
}

View File

@@ -0,0 +1,193 @@
'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);
const defectLabel = detections[0]?.detection.display_name ?? 'Detected';
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" />
{defectLabel} 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 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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')}`;
}

View File

@@ -0,0 +1,325 @@
'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,
useTicketClassReviewDetectionsQuery,
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 TicketClassReviewPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { ticketId, defectClass } = useParams() as {
ticketId: string;
defectClass: 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 = useTicketClassReviewDetectionsQuery(
videoId,
defectClass,
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,
defectClass,
});
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 = () => {
const params = new URLSearchParams({ defect_class: defectClass });
router.push(`${ROUTES.TICKET_DETAIL(ticketId)}?${params.toString()}`);
};
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 class 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}
defectLabel={defectClass}
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 {defectClass} 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}
defectLabel={defectLabel}
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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,97 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
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 TicketClassContentSkeleton() {
return (
<div className="space-y-4" aria-label="Loading defect class details">
<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>
);
}
export function TicketClassActionsSkeleton() {
return (
<Card aria-label="Loading defect class actions">
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded-lg" />
<Skeleton className="h-5 w-32" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-20 w-full" />
</div>
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,314 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import {
useDiscardDetectionMutation,
useTicketClassDetectionsQuery,
} from '../../hooks/useTicketQueries';
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketClassDetectionPreviewProps {
ticketId: string;
videoId?: string | null;
defectClassName: string;
displayName: string;
totalCount?: number;
}
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 TicketClassDetectionPreviewSkeleton() {
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 TicketClassDetectionPreview({
ticketId,
videoId,
defectClassName,
displayName,
totalCount,
}: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon;
useEffect(() => {
setCurrentIndex(0);
}, [defectClassName]);
const queryParams = useMemo(
() => ({
class_name: defectClassName,
skip: currentIndex,
limit: 1,
sort: 'timestamp_asc',
}),
[currentIndex, defectClassName],
);
const detectionsQuery = useTicketClassDetectionsQuery(
videoId ?? undefined,
queryParams,
);
const discardMutation = useDiscardDetectionMutation(
ticketId,
videoId ?? undefined,
defectClassName,
);
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
useEffect(() => {
if (detectionCount === 0 && currentIndex !== 0) {
setCurrentIndex(0);
return;
}
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
setCurrentIndex(detectionCount - 1);
}
}, [currentIndex, detectionCount]);
const isNavigationDisabled =
detectionCount === 0 || detectionsQuery.isLoading;
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',
visual.cardClassName,
)}
>
<IssueIcon className={cn('size-5', visual.colorClassName)} />
</span>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3>{displayName}</h3>
<Badge
variant="secondary"
className={cn(
'rounded-lg',
visual.cardClassName,
visual.colorClassName,
)}
>
{detectionCount} Detections
</Badge>
</div>
<p className="text-muted-foreground">
Review detections in ascending timestamp order.
</p>
</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>
{detectionsQuery.isLoading && !detectionsQuery.data ? (
<TicketClassDetectionPreviewSkeleton />
) : detectionsQuery.isError ? (
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
<div className="flex flex-col items-center gap-2">
<AlertTriangle className="size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load detection preview.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void detectionsQuery.refetch()}
>
Retry
</Button>
</div>
</div>
) : !activeDetection || !detectionResult ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
No detections are available for this issue.
</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={`${displayName} - 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`,
},
]}
/>
<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);
}}
/>
</>
)}
</div>
);
}

View File

@@ -2,22 +2,26 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Component } from 'lucide-react'; import { Component, ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus'; import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries'; import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps { interface TicketDefectClassTabsProps {
ticketId: string; ticketId: string;
ticket?: TicketOverviewDetail;
} }
export function TicketDefectClassTabs({ export function TicketDefectClassTabs({
ticketId, ticketId,
ticket,
}: TicketDefectClassTabsProps) { }: TicketDefectClassTabsProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -37,15 +41,17 @@ export function TicketDefectClassTabs({
(item) => item.class_name === defectClassParam, (item) => item.class_name === defectClassParam,
); );
setSelectedClass((current) => { if (hasUrlClass) {
if (hasUrlClass) return defectClassParam ?? undefined; setSelectedClass(defectClassParam ?? undefined);
return;
}
return current && const firstClass = defectClasses[0].class_name;
defectClasses.some((item) => item.class_name === current) const nextParams = new URLSearchParams(searchParams.toString());
? current nextParams.set('defect_class', firstClass);
: defectClasses[0].class_name; setSelectedClass(firstClass);
}); router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
}, [defectClassParam, defectClasses]); }, [defectClassParam, defectClasses, pathname, router, searchParams]);
const handleClassChange = (className: string) => { const handleClassChange = (className: string) => {
const nextParams = new URLSearchParams(searchParams.toString()); const nextParams = new URLSearchParams(searchParams.toString());
@@ -54,20 +60,32 @@ export function TicketDefectClassTabs({
setSelectedClass(className); setSelectedClass(className);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }); router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
}; };
const selectedIssue = ticket?.ai_result.detections_by_class.find(
(item) => item.class_name === selectedClass,
);
const selectedClassItem = defectClasses.find(
(item) => item.class_name === selectedClass,
);
const previewDisplayName =
selectedIssue?.display_name ??
selectedClassItem?.display_name ??
selectedClass ??
'Detection';
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
if (defectClassesQuery.isLoading) { if (defectClassesQuery.isLoading) {
return ( return (
<Card className="w-full"> <Card className="w-full rounded-lg">
<CardHeader> <CardContent className="space-y-4 p-5">
<div className="h-5 w-24 animate-pulse rounded bg-muted" /> <div className="h-5 w-24 animate-pulse rounded-lg bg-muted" />
</CardHeader> <div className="flex flex-col gap-2">
<CardContent className="flex flex-col gap-2"> {Array.from({ length: 3 }).map((_, index) => (
{Array.from({ length: 3 }).map((_, index) => ( <div
<div key={index}
key={index} className="h-11 w-full animate-pulse rounded-lg bg-muted"
className="h-11 w-full animate-pulse rounded-lg bg-muted" />
/> ))}
))} </div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -85,45 +103,60 @@ export function TicketDefectClassTabs({
className="w-full" className="w-full"
> >
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
<CardTitle className="flex items-center gap-2 text-base"> <div className="space-y-4 px-5">
<span>Issues</span> <div className="flex items-center gap-2">
<Badge variant="secondary" className="min-w-5 min-h-5 justify-center rounded-full"> <ListChecks className="size-5 shrink-0 text-primary" />
{defectClasses.length} <h2 className="text-base font-semibold">Detected Issues</h2>
</Badge> <Badge
</CardTitle> variant="secondary"
</CardHeader> className="min-w-5 min-h-5 justify-center rounded-full"
<CardContent> >
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0"> {defectClasses.length}
{defectClasses.map((item) => { </Badge>
const statusConfig = </div>
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
return ( <TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
<TabsTrigger {defectClasses.map((item) => {
key={item.class_name} const statusConfig =
value={item.class_name} DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
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', return (
'data-[state=active]:bg-muted data-[state=active]:text-foreground', <TabsTrigger
)} key={item.class_name}
> value={item.class_name}
<span className="min-w-0 truncate text-left font-medium">
{item.display_name}
</span>
<Badge
className={cn( className={cn(
'shrink-0 gap-1.5 rounded-full border-0 px-2.5 py-1 text-xs font-medium', 'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
statusConfig.badgeClassName, 'data-[state=active]:bg-muted data-[state=active]:text-foreground',
)} )}
> >
<Component className="size-3" /> <span className="min-w-0 truncate text-left font-medium">
{statusConfig.label} {item.display_name}
</Badge> </span>
</TabsTrigger> <Badge
); className={cn(
})} 'shrink-0 rounded-full',
</TabsList> statusConfig.badgeClassName,
)}
>
<Component className="size-3" />
{statusConfig.label}
</Badge>
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="min-w-0 px-5">
<TicketClassDetectionPreview
ticketId={ticketId}
videoId={previewVideoId}
defectClassName={selectedClass}
displayName={previewDisplayName}
totalCount={selectedIssue?.count}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</Tabs> </Tabs>

View File

@@ -4,14 +4,15 @@ import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { SparkleButton } from '@/components/ui/sparkle-button'; import type { TicketOverviewDetail } from '@/types';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) { export function TicketDetailHeader({
ticket,
}: {
ticket: TicketOverviewDetail;
}) {
const router = useRouter(); const router = useRouter();
const ticketLabel = ticket.ticket_name; const ticketLabel = ticket.ticket_name || ticket.id;
return ( return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@@ -21,20 +22,8 @@ export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) {
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{ticket.video_id ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<SparkleButton
className="h-10 min-w-36 px-5"
onClick={() => router.push(`/results/${ticket.video_id}`)}
>
View Analysis
</SparkleButton>
</PermissionGuard>
) : null}
<Button <Button
variant="outline" variant="secondary"
size="sm"
onClick={() => router.push('/ticket')} onClick={() => router.push('/ticket')}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />

View File

@@ -1,115 +0,0 @@
'use client';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
function OverviewSkeletonField() {
return (
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-32" />
</div>
);
}
function TimelineSkeletonItem({ isLast = false }: { isLast?: boolean }) {
return (
<div className="relative flex gap-4 pb-8 last:pb-0">
{!isLast ? (
<span
aria-hidden
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>
);
}
export function TicketDetailSkeleton() {
return (
<main className="relative z-10 space-y-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
<Skeleton className="h-3 w-72 max-w-full" />
</div>
<div className="flex shrink-0 items-center gap-2">
<Skeleton className="h-10 w-36" />
<Skeleton className="h-8 w-28" />
</div>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
<div className="space-y-5">
<Card>
<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: 8 }).map((_, index) => (
<OverviewSkeletonField key={index} />
))}
</CardContent>
</Card>
<section className="space-y-5">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-44" />
</CardHeader>
<CardContent>
<Skeleton className="aspect-video w-full rounded-lg" />
</CardContent>
</Card>
</section>
<Card>
<CardHeader className="flex-row items-center">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-5 w-56" />
</CardHeader>
<CardContent className="pl-5.5">
<TimelineSkeletonItem />
<TimelineSkeletonItem />
<TimelineSkeletonItem isLast />
</CardContent>
</Card>
</div>
<Card className="self-start">
<CardHeader>
<Skeleton className="h-5 w-36" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -2,87 +2,84 @@
import { History, MessageSquareText } from 'lucide-react'; import { History, MessageSquareText } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import { Timeline, TimelineItem } from '@/components/ui/timeline';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types'; import type { TicketDetail, TicketHistoryItem, TicketHistorySource } from '@/types';
import { formatDate } from '@/utils/date'; import { formatTimelineDateTime } from '@/utils/date';
import { getPersonLabel } from '@/utils/person';
function getActorLabel(item: TicketHistoryItem) { function TimelineDate({ value }: { value: string }) {
return item.actor?.name || item.actor?.email || 'System'; const { date, time } = formatTimelineDateTime(value);
}
function getLifecycleTitle(status: TicketStatus) {
const titles: Record<TicketStatus, string> = {
unassigned: 'Awaiting Assignment',
assigned: 'Dispatched to Maintenance',
under_review: 'Maintenance Finalized',
closed: 'Ticket Closed',
};
return titles[status];
}
function HistoryEntry({
item,
isLast,
}: {
item: TicketHistoryItem;
isLast: boolean;
}) {
const isClosed = item.to_status === 'closed';
return ( return (
<div className="relative flex gap-4 pb-8 last:pb-0"> <time dateTime={value} className="block tabular-nums">
{!isLast ? ( <span className="block text-sm font-semibold text-foreground">{date}</span>
<span <span className="block text-xs text-muted-foreground">{time}</span>
aria-hidden </time>
className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" );
/> }
) : null}
<span function HistoryNote({ item }: { item: TicketHistoryItem }) {
aria-hidden const noteText = item.note?.text?.trim();
className={cn( if (!noteText) return null;
'relative z-10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border-2 bg-background',
isClosed
? 'border-primary bg-primary text-primary-foreground'
: 'border-primary/60',
)}
>
<span
className={cn(
'size-2 rounded-full bg-primary',
isClosed &&
'h-1.5 w-2.5 -rotate-45 rounded-none border-b-2 border-l-2 border-current bg-transparent',
)}
/>
</span>
<div className="min-w-0 flex-1 space-y-2"> return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> <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">
<h3 className="text-sm font-semibold text-foreground"> <div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
{getLifecycleTitle(item.to_status)} <MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
</h3> <span>
<time Note by {getPersonLabel(item.note?.author ?? item.actor)}
dateTime={item.created_at} </span>
className="text-xs font-semibold text-muted-foreground tabular-nums"
>
{formatDate(item.created_at)}
</time>
</div>
<p className="text-xs font-medium text-muted-foreground">
Updated By{' '}
<span className="text-foreground/90">{getActorLabel(item)}</span>
</p>
{item.note ? (
<div className="flex gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm leading-relaxed text-foreground">
<MessageSquareText className="mt-0.5 size-4 shrink-0 text-primary" />
<p>{item.note}</p>
</div>
) : null}
</div> </div>
<p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90">
{noteText}
</p>
</div>
);
}
function getSourceLabel(source: TicketHistorySource) {
return source === 'system' ? 'System Generated' : 'User Action';
}
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
const noteText = item.note?.text?.trim();
return (
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
<Badge
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
>
{getSourceLabel(item.source)}
</Badge>
<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}
</p>
) : null}
</div>
);
}
function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
const noteText = item.note?.text?.trim();
const isAssigned = item.event_type === 'assigned';
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 ? (
<div className="mt-3 space-y-1">
<p className="text-xs text-muted-foreground">Assigned to</p>
<PersonInfo person={item.target_user} />
</div>
) : null}
{noteText ? <HistoryNote item={item} /> : null}
</div> </div>
); );
} }
@@ -91,33 +88,48 @@ export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
const history = ticket.history ?? []; const history = ticket.history ?? [];
return ( return (
<Card> <div className="space-y-4">
<CardHeader> <div className="flex items-center gap-2">
<CardTitle className="flex items-center gap-2"> <History className="size-5 text-primary" />
<History className="size-5 text-primary" /> <h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
Audit & Lifecycle Timeline </div>
</CardTitle>
</CardHeader>
<CardContent> {history.length ? (
{history.length ? ( <Timeline>
<div className="pl-0.5"> {history.map((item, index) => (
{history.map((item, index) => ( <TimelineItem
<HistoryEntry key={item.id}
key={item.id} layout="left"
item={item} isLast={index === history.length - 1}
isLast={index === history.length - 1} opposite={<TimelineDate value={item.created_at} />}
/> marker={
))} <span className="flex size-4 items-center justify-center rounded-full border bg-background shadow-xs">
</div> <span
) : ( className={cn(
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center"> 'size-1.5 rounded-full',
<p className="text-sm font-medium text-muted-foreground"> item.source === 'system'
No timeline entries yet ? 'bg-muted-foreground'
</p> : 'bg-primary',
</div> )}
)} />
</CardContent> </span>
</Card> }
>
{item.source === 'system' ? (
<SystemHistoryEntry item={item} />
) : (
<UserHistoryEntry item={item} />
)}
</TimelineItem>
))}
</Timeline>
) : (
<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>
</div>
)}
</div>
); );
} }

View File

@@ -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>
);
}

View File

@@ -1,28 +1,42 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import {
import type { TicketActor, TicketDetail } from '@/types'; Activity,
CalendarDays,
Clock3,
Gauge,
MapPin,
Monitor,
UserRound,
Play
} from 'lucide-react';
import { useState } from 'react';
function formatCreatedDate(value: string | null | undefined) { import {
if (!value) return '-'; Card,
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format( CardAction,
new Date(value), CardContent,
); CardHeader,
} 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';
function getInitials(person: TicketActor | null | undefined) { type IconComponent = React.ComponentType<{ className?: string }>;
const source = person?.name || person?.email || '?';
return source
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
}
function getPersonLabel(person: TicketActor | null | undefined) {
return person?.name || person?.email || '-';
}
function MetaLabel({ children }: { children: React.ReactNode }) { function MetaLabel({ children }: { children: React.ReactNode }) {
return ( return (
@@ -36,29 +50,6 @@ function MetaValue({ children }: { children: React.ReactNode }) {
return <p className="text-sm font-semibold text-foreground">{children}</p>; return <p className="text-sm font-semibold text-foreground">{children}</p>;
} }
function PersonMetaValue({
person,
accentClassName = 'bg-primary/15 text-primary',
}: {
person: TicketActor | null | undefined;
accentClassName?: string;
}) {
if (!person) {
return <MetaValue>-</MetaValue>;
}
return (
<div className="flex min-w-0 items-center gap-2">
<span
className={`flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${accentClassName}`}
>
{getInitials(person)}
</span>
<p className="truncate text-sm font-medium">{getPersonLabel(person)}</p>
</div>
);
}
function OverviewField({ function OverviewField({
label, label,
children, children,
@@ -67,43 +58,193 @@ function OverviewField({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="space-y-1"> <div className="min-w-0 space-y-1">
<MetaLabel>{label}</MetaLabel> <MetaLabel>{label}</MetaLabel>
{children} {children}
</div> </div>
); );
} }
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) { function SummaryCard({
const detectionCount = ticket.ai_result?.detection_count; label,
value,
colorClassName,
cardClassName,
}: {
label: string;
value: number;
colorClassName: string;
cardClassName: string;
}) {
return ( return (
<Card> <div
<CardHeader> className={cn(
<CardTitle>Overview</CardTitle> 'rounded-lg bg-muted/40 p-4 hover:scale-105 transition-transform ease-in-out',
</CardHeader> cardClassName,
)}
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4"> >
<OverviewField label="Detection Count"> <p className={cn('text-2xl font-bold tracking-tight', colorClassName)}>
<MetaValue> {value}
{detectionCount != null ? `${detectionCount} Occurrences` : '-'} </p>
</MetaValue> <p className="mt-2 truncate text-xs font-semibold text-foreground">
</OverviewField> {label}
</p>
<OverviewField label="Chainage"> <p className="mt-0.5 text-[11px] text-muted-foreground">Detected</p>
<MetaValue>{ticket.chainage_name}</MetaValue> </div>
</OverviewField> );
}
<OverviewField label="Uploaded By">
<PersonMetaValue person={ticket.uploader} /> function VideoMetric({
</OverviewField> icon: Icon,
label,
<OverviewField label="Created Date"> }: {
<MetaValue> icon: IconComponent;
{formatCreatedDate(ticket.timestamps.created_at)} label: string;
</MetaValue> }) {
</OverviewField> return (
</CardContent> <div className="flex items-center justify-center gap-2 font-medium">
</Card> <Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</div>
);
}
export function TicketOverviewCard({
ticket,
}: {
ticket: TicketOverviewDetail;
}) {
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(', ') || 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">
{uploadedOn || uploaderName || locationLabel ? (
<Card>
<CardContent className="grid gap-4 py-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>
{analysisVideoId ? (
<CardAction>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<Button
variant="secondary"
onClick={() => setIsAnalysisOpen(true)}
>
<Play className="mr-2 size-4" />
View Video
</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 (
<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>
<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>
</div>
); );
} }

View File

@@ -0,0 +1,89 @@
import {
Card,
CardAction,
CardContent,
CardHeader,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
function OverviewMetaSkeletonField({ className }: { className?: string }) {
return (
<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>
);
}
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>
<div className="flex shrink-0 items-center gap-2">
<Skeleton className="h-8 w-28" />
</div>
</div>
);
}
export function TicketOverviewCardSkeleton() {
return (
<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>
);
}

View File

@@ -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>
);
}

View File

@@ -4,12 +4,13 @@ import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types'; import type { TicketDetail } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { AssignTicketAction } from './actions/AssignTicketAction'; import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction'; import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction'; import { NoTicketAction } from './actions/NoTicketAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction'; import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction'; import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { TicketDefectClassTabs } from './TicketDefectClassTabs'; import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
interface TicketStatusActionsProps { interface TicketStatusActionsProps {
ticket: TicketDetail; ticket: TicketDetail;
@@ -30,10 +31,20 @@ function getTicketAction(ticket: TicketDetail) {
if (ticket.assignment_status === 'assigned') { if (ticket.assignment_status === 'assigned') {
return ( return (
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.WORK} permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />} fallback={
<PermissionGuard
permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction />}
>
<RequestExtensionAction ticket={ticket} />
</PermissionGuard>
}
> >
<SubmitRepairAction ticket={ticket} /> <>
<AssignedContractorAction ticket={ticket} />
<ReviewExtensionRequestAction ticket={ticket} />
</>
</PermissionGuard> </PermissionGuard>
); );
} }
@@ -44,12 +55,15 @@ function getTicketAction(ticket: TicketDetail) {
permissions={PERMISSIONS.TICKET.REVIEW} permissions={PERMISSIONS.TICKET.REVIEW}
fallback={<NoTicketAction />} fallback={<NoTicketAction />}
> >
<ReviewRepairAction ticket={ticket} /> <OpenRepairReviewAction ticket={ticket} />
</PermissionGuard> </PermissionGuard>
); );
} }
if (ticket.status === 'closed') { if (
ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected'
) {
return <ClosedTicketAction ticket={ticket} />; return <ClosedTicketAction ticket={ticket} />;
} }
@@ -57,10 +71,5 @@ function getTicketAction(ticket: TicketDetail) {
} }
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) { export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return ( return <div className="space-y-3">{getTicketAction(ticket)}</div>;
<div className="space-y-3">
<TicketDefectClassTabs ticketId={ticket.id} />
{getTicketAction(ticket)}
</div>
);
} }

View File

@@ -1,56 +1,109 @@
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { Send, UserPlus } from 'lucide-react'; import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect'; import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import type { TicketDetail } from '@/types'; import type { AssignableTicketUser, TicketDetail } from '@/types';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries'; import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) { export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
const [selectedUserId, setSelectedUserId] = useState(''); const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState(''); const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticket.id); const assignMutation = useAssignTicketMutation(ticket.id);
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],
);
return ( return (
<TicketActionCard icon={UserPlus} title="Assign Ticket"> <TicketActionCard icon={Users} title="Assign to Contractor">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Contractor</Label> <Label>Contractor *</Label>
<AssignableWorkerSelect <AssignableWorkerSelect
value={selectedUserId} value={selectedUserId}
onValueChange={setSelectedUserId} onValueChange={(userId, user) => {
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending} disabled={assignMutation.isPending}
/> />
</div> </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"> <div className="space-y-2">
<Label>Note</Label> <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>Assignment Note (Optional)</Label>
<Textarea <Textarea
value={assignNote} value={assignNote}
onChange={(event) => setAssignNote(event.target.value)} onChange={(event) => setAssignNote(event.target.value)}
placeholder="Please inspect and repair" placeholder="Add any instructions or notes for the contractor..."
/> />
</div> </div>
<Button <Button
disabled={!selectedUserId || assignMutation.isPending} disabled={!selectedUserId || !dueDate || assignMutation.isPending}
onClick={() => { onClick={() => {
if (!selectedUserId) return; if (!selectedUserId || !dueDate) return;
assignMutation.mutate({ assignMutation.mutate({
assigned_to_user_id: Number(selectedUserId), assigned_to_user_id: Number(selectedUserId),
defect_class: ticket.defect_class, defect_class: ticket.defect_class,
due_at: dueDate.toISOString(),
note: assignNote || undefined, note: assignNote || undefined,
}); });
}} }}
className="w-full" className="w-full"
> >
<Send /> <Send />
Assign Assign Ticket
</Button> </Button>
</div> </div>
</TicketActionCard> </TicketActionCard>

View File

@@ -0,0 +1,61 @@
'use client';
import { CalendarClock, UserCheck } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import type { TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { TicketActionCard } from './TicketActionCard';
function AssignmentField({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate text-sm font-medium text-foreground">{value}</p>
</div>
);
}
export function AssignedContractorAction({ ticket }: { ticket: TicketDetail }) {
const worker = ticket.worker;
return (
<TicketActionCard icon={UserCheck} title="Assigned Contractor">
{worker ? (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<PersonInfo person={worker} size="lg" />
<Badge>
Assigned
</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-5 border-t pt-4">
<AssignmentField label="Role" value={worker.role?.name ?? '-'} />
<AssignmentField
label="Assigned By"
value={worker.assigned_by_name ?? '-'}
/>
<AssignmentField
label="Assigned On"
value={formatDate(worker.assigned_at)}
/>
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">Due Date</p>
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<CalendarClock className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{formatDate(worker.due_at)}</span>
</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Assignment details are not available.
</p>
)}
</TicketActionCard>
);
}

View File

@@ -3,7 +3,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Lock, MessageSquare } from 'lucide-react'; import { Lock, MessageSquare } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { PersonInfo } from '@/components/person-avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@@ -14,34 +14,18 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TicketDetail } from '@/types'; import type { TicketDetail } from '@/types';
import { formatDateOnly } from '@/utils/date';
function getInitials(name?: string | null, email?: string | null) {
const source = name || email || '?';
return source
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
}
function getPersonLabel(person: TicketDetail['reviewer']) {
return person?.name || person?.email || 'Unknown';
}
function formatReviewDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
new Date(value),
);
}
function getClosedAt(ticket: TicketDetail) { function getClosedAt(ticket: TicketDetail) {
const closedEntry = ticket.history?.find( const approvedEntry = [...(ticket.history ?? [])]
(item) => item.to_status === 'closed', .reverse()
); .find(
(item) =>
item.event_type === 'repair_approved' ||
item.to_class_status === 'approved',
);
return ( return (
closedEntry?.created_at ?? approvedEntry?.created_at ??
ticket.reviewer?.reviewed_at ?? ticket.reviewer?.reviewed_at ??
ticket.timestamps.updated_at ticket.timestamps.updated_at
); );
@@ -101,35 +85,19 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Resolution Details</CardTitle> <CardTitle className="text-lg">Resolution Details</CardTitle>
<CardDescription>Review record for {ticket.id}</CardDescription> <CardDescription>Review record for {ticket.ticket_name}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="space-y-4 rounded-xl border bg-muted/20 p-4"> <div className="space-y-4 rounded-lg border bg-muted/20 p-4">
<MetaField <MetaField
label="Reviewed By" label="Reviewed By"
value={ value={<PersonInfo person={reviewer} size="lg" />}
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-10 border border-primary/30">
<AvatarFallback className="bg-primary/15 text-sm text-primary">
{getInitials(reviewer?.name, reviewer?.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate">{getPersonLabel(reviewer)}</p>
{reviewer?.email ? (
<p className="mt-0.5 truncate text-xs font-medium text-muted-foreground">
{reviewer.email}
</p>
) : null}
</div>
</div>
}
/> />
<MetaField <MetaField
label="Reviewed Date" label="Reviewed Date"
value={formatReviewDate(reviewedAt)} value={formatDateOnly(reviewedAt)}
/> />
</div> </div>

View File

@@ -0,0 +1,37 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowRight, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes';
import { TicketActionCard } from './TicketActionCard';
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) {
const router = useRouter();
return (
<TicketActionCard icon={ClipboardCheck} title="Review Submitted Repairs">
<div className="space-y-4">
<p className="text-sm leading-relaxed text-muted-foreground">
Review every submitted repair separately using its detection image,
location, and repair proof.
</p>
<Button
type="button"
className="w-full"
onClick={() =>
router.push(
ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class),
)
}
>
Open Issue Review
<ArrowRight />
</Button>
</div>
</TicketActionCard>
);
}

View File

@@ -1,313 +0,0 @@
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { ImageIcon, Loader2, 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 = 5;
function RequiredMark() {
return <span className="text-destructive">*</span>;
}
interface MediaDropzoneProps {
icon: ReactNode;
label: string;
accept: string;
file: File | null;
onFileChange: (file: File | null) => void;
disabled?: boolean;
}
function MediaDropzone({
icon,
label,
accept,
file,
onFileChange,
disabled = false,
}: MediaDropzoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
return (
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
className={cn(
'flex w-full items-center cursor-pointer gap-3 rounded-lg border border-dashed px-4 py-3 text-left transition-colors',
'hover:border-primary/50 hover:bg-muted/30',
disabled && 'pointer-events-none opacity-60',
file && 'border-primary/40 bg-muted/20',
)}
>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
disabled={disabled}
onChange={(event) => {
onFileChange(event.target.files?.[0] ?? null);
event.target.value = '';
}}
/>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{file ? file.name : label}
</span>
{file ? (
<span className="text-xs text-muted-foreground">
Click to replace
</span>
) : null}
</span>
</button>
);
}
interface ImageSlotProps {
file: File | null;
previewUrl: string | null;
disabled?: boolean;
onAdd: () => void;
onRemove: () => void;
}
function ImageSlot({
file,
previewUrl,
disabled = false,
onAdd,
onRemove,
}: ImageSlotProps) {
if (file && previewUrl) {
return (
<div className="group relative aspect-square 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-background/90 text-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${file.name}`}
>
<X className="size-3" />
</button>
</div>
);
}
return (
<button
type="button"
disabled={disabled}
onClick={onAdd}
className={cn(
'flex aspect-square cursor-pointer items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground transition-colors',
'hover:border-primary/50 hover:bg-muted/40 hover:text-primary',
disabled && 'cursor-not-allowed opacity-60',
)}
aria-label="Add image"
>
<ImageIcon className="size-4" />
</button>
);
}
interface RepairEvidenceFormProps {
ticketId: string;
defectClass: string;
}
export function RepairEvidenceForm({
ticketId,
defectClass,
}: RepairEvidenceFormProps) {
const imageInputRef = useRef<HTMLInputElement | null>(null);
const [notes, setNotes] = useState('');
const [videoFile, setVideoFile] = useState<File | 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);
useEffect(() => {
const urls = imageFiles.map((file) => URL.createObjectURL(file));
setImagePreviews(urls);
return () => {
urls.forEach((url) => URL.revokeObjectURL(url));
};
}, [imageFiles]);
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 removeImage = (index: number) => {
setImageFiles((current) =>
current.filter((_, itemIndex) => itemIndex !== index),
);
setError(null);
};
const handleSubmit = async () => {
if (!notes.trim() || !videoFile) {
setError('Repair notes and 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 imageSlots = Array.from({ length: MAX_IMAGES }, (_, index) => ({
file: imageFiles[index] ?? null,
previewUrl: imagePreviews[index] ?? null,
}));
return (
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="repair-notes">
Repair Notes <RequiredMark />
</Label>
<Textarea
id="repair-notes"
value={notes}
onChange={(event) => {
setNotes(event.target.value);
setError(null);
}}
placeholder="Describe the repair work performed..."
disabled={isSubmitting}
className="min-h-24 resize-none"
/>
</div>
<div className="space-y-3">
<Label className="text-muted-foreground">Media Upload</Label>
<div className="space-y-2">
<Label className="text-sm font-normal">
Repair Video <RequiredMark />
</Label>
<MediaDropzone
icon={<Video className="size-4" />}
label="Upload Repair Video (MP4, max 50MB)"
accept="video/mp4,video/*"
file={videoFile}
onFileChange={(file) => {
setVideoFile(file);
setError(null);
}}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-normal">
Upload Images (Max {MAX_IMAGES})
</Label>
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
disabled={isSubmitting || imageFiles.length >= MAX_IMAGES}
onChange={(event) => {
addImages(event.target.files);
event.target.value = '';
}}
/>
<div className="grid grid-cols-5 gap-2">
{imageSlots.map((slot, index) => (
<ImageSlot
key={index}
file={slot.file}
previewUrl={slot.previewUrl}
disabled={
isSubmitting ||
(!slot.file && imageFiles.length >= MAX_IMAGES)
}
onAdd={() => imageInputRef.current?.click()}
onRemove={() => removeImage(index)}
/>
))}
</div>
</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>
);
}

View File

@@ -0,0 +1,14 @@
import { Clock3 } from 'lucide-react';
import type { TicketDetail } from '@/types';
import { TicketActionCard } from './TicketActionCard';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) {
return (
<TicketActionCard icon={Clock3} title="Request Extension">
<TicketExtensionRequestPanel ticket={ticket} />
</TicketActionCard>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
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;
}
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
const currentDueAt = ticket.worker?.due_at;
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,
assignmentId,
ticket.defect_class,
);
const isPending = requestExtensionMutation.isPending;
const isRequestedDateValid = Boolean(
requestedDueDate &&
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
);
const canSubmit =
Boolean(assignmentId) &&
isRequestedDateValid &&
Boolean(reason.trim()) &&
!isPending;
const handleSubmit = async () => {
if (!requestedDueDate || !canSubmit) return;
await requestExtensionMutation.mutateAsync({
requested_due_at: requestedDueDate.toISOString(),
reason: reason.trim(),
});
setRequestedDueDate(undefined);
setReason('');
};
return (
<div className="space-y-5">
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
<Clock3 className="mt-0.5 size-5 shrink-0 text-primary" />
<p className="text-sm text-muted-foreground">
Request more time to complete the repair. Your manager will review the
request.
</p>
</div>
<div className="space-y-2">
<Label>Current Due Date</Label>
<DatePicker
value={currentDueDate}
showTimeZone={false}
placeholder="No due date available"
disabled
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>
Requested Due Date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="requested-due-date"
value={requestedDueDate}
onChange={setRequestedDueDate}
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">
Requested date must be after the current due date.
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="extension-reason">
Reason for Extension <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Textarea
id="extension-reason"
value={reason}
onChange={(event) =>
setReason(event.target.value.slice(0, MAX_REASON_LENGTH))
}
placeholder="Explain why additional time is required to complete the repair."
disabled={isPending}
className="min-h-28 resize-none pb-8"
/>
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
{reason.length} / {MAX_REASON_LENGTH}
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
This request will be sent to your manager for approval.
</p>
<Button
type="button"
className="w-full"
disabled={!canSubmit}
onClick={handleSubmit}
>
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
{isPending ? 'Submitting...' : 'Submit Request'}
</Button>
</div>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import { useState } from 'react';
import {
ArrowRight,
CalendarClock,
Check,
Clock3,
Loader2,
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,
TicketExtensionRequest,
TicketExtensionRequestsResponse,
} from '@/types';
import { formatDate } from '@/utils/date';
import {
useReviewTicketExtensionMutation,
useTicketExtensionRequestQuery,
} from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard';
const MAX_NOTE_LENGTH = 300;
function getPendingRequest(response: TicketExtensionRequestsResponse) {
return response.items
.filter((request) => request.status === 'pending')
.reduce<TicketExtensionRequest | null>((latest, request) => {
if (!latest) return request;
return new Date(request.requested_at) > new Date(latest.requested_at)
? request
: latest;
}, null);
}
function getExtensionDays(request: TicketExtensionRequest) {
const difference =
new Date(request.requested_due_at).getTime() -
new Date(request.current_due_at).getTime();
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
}
function Requester({ request }: { request: TicketExtensionRequest }) {
const name = request.requested_by?.name ?? 'Assigned worker';
const initials = name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('');
return (
<div className="grid grid-cols-2 gap-3 rounded-lg bg-muted/40 p-3">
<div className="flex items-center gap-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{initials || 'W'}
</span>
<div className="min-w-0">
<p className="text-[11px] text-muted-foreground">Requested By</p>
<p className="truncate text-sm font-medium">{name}</p>
</div>
</div>
<div>
<p className="text-[11px] text-muted-foreground">Requested On</p>
<p className="text-sm font-medium">
{formatDate(request.requested_at)}
</p>
</div>
</div>
);
}
function ExtensionReviewForm({
ticket,
request,
}: {
ticket: TicketDetail;
request: 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 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">
<div className="space-y-4">
<Requester request={request} />
<section className="space-y-2">
<p className="text-xs font-semibold">Request Summary</p>
<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-[11px] text-muted-foreground">
Current Due Date
</p>
<p className="text-xs font-medium">
{formatDate(request.current_due_at)}
</p>
</div>
<ArrowRight className="mt-6 size-4 text-muted-foreground" />
<div>
<p className="text-[11px] text-muted-foreground">
Requested Due Date
</p>
<p className="text-xs font-medium">
{formatDate(request.requested_due_at)}
</p>
{extensionDays > 0 ? (
<span className="mt-1 inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
+{extensionDays} {extensionDays === 1 ? 'Day' : 'Days'}
</span>
) : null}
</div>
</div>
<div className="rounded-lg border p-3">
<p className="text-[11px] text-muted-foreground">Reason</p>
<p className="mt-1 text-sm leading-relaxed">{request.reason}</p>
</div>
</section>
<div className="space-y-2">
<Label htmlFor="extension-manager-note">
Add Manager Comments{' '}
<span className="text-muted-foreground">(Optional)</span>
</Label>
<div className="relative">
<Textarea
id="extension-manager-note"
value={note}
onChange={(event) =>
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH))
}
placeholder="Add a note explaining your decision..."
disabled={reviewMutation.isPending}
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}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
type="button"
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
disabled={reviewMutation.isPending}
onClick={() => submitDecision('reject')}
>
{selectedAction === 'reject' ? (
<Loader2 className="animate-spin" />
) : (
<X />
)}
Reject Request
</Button>
<Button
type="button"
disabled={reviewMutation.isPending}
onClick={() => submitDecision('approve')}
>
{selectedAction === 'approve' ? (
<Loader2 className="animate-spin" />
) : (
<Check />
)}
Approve Extension
</Button>
</div>
</div>
</TicketActionCard>
);
}
export function ReviewExtensionRequestAction({
ticket,
}: {
ticket: TicketDetail;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
assignmentId,
);
if (!assignmentId || extensionRequestQuery.isError) {
return null;
}
if (extensionRequestQuery.isLoading) {
return (
<TicketActionCard icon={Clock3} title="Extension Request">
<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>
</div>
</TicketActionCard>
);
}
const pendingRequest = extensionRequestQuery.data
? getPendingRequest(extensionRequestQuery.data)
: null;
if (!pendingRequest) return null;
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
}

View File

@@ -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>
);
}

View File

@@ -1,19 +0,0 @@
'use client';
import { Wrench } from 'lucide-react';
import type { TicketDetail } from '@/types';
import { RepairEvidenceForm } from './RepairEvidenceForm';
import { TicketActionCard } from './TicketActionCard';
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
return (
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
<RepairEvidenceForm
ticketId={ticket.id}
defectClass={ticket.defect_class}
/>
</TicketActionCard>
);
}

View File

@@ -22,7 +22,7 @@ export function TicketActionCard({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Icon className={cn('size-4 text-primary', iconClassName)} /> <Icon className={cn('text-primary', iconClassName)} />
{title} {title}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>

View File

@@ -0,0 +1,244 @@
'use client';
import {
AlertCircle,
CalendarDays,
CheckCircle2,
Clock3,
History,
Loader2,
RotateCcw,
XCircle,
} from 'lucide-react';
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 { RequestExtensionForm } from './RequestExtensionForm';
interface DetailRowProps {
label: string;
value: string;
children?: React.ReactNode;
className?: string;
}
function DetailRow({ label, value, children, className }: DetailRowProps) {
return (
<div className={cn('space-y-1 px-3 py-2.5', className)}>
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">{value}</p>
{children}
</div>
</div>
);
}
const statusConfig = {
pending: {
title: 'Request Pending',
description: 'Your 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.',
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.',
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',
},
cancelled: {
title: 'Request Cancelled',
description: 'This extension request was cancelled.',
icon: XCircle,
className: 'border-border bg-muted/50 text-muted-foreground',
},
} satisfies Record<
TicketExtensionRequest['status'],
{
title: string;
description: string;
icon: typeof Clock3;
className: string;
}
>;
function getLatestRequest(response: TicketExtensionRequestsResponse) {
return response.items.reduce<TicketExtensionRequest | null>(
(latest, item) => {
if (!latest) return item;
return new Date(item.requested_at) > new Date(latest.requested_at)
? item
: latest;
},
null,
);
}
function getExtensionDays(request: TicketExtensionRequest) {
const difference =
new Date(request.requested_due_at).getTime() -
new Date(request.current_due_at).getTime();
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
}
function ExtensionRequestDetails({
request,
}: {
request: TicketExtensionRequest;
}) {
const config = statusConfig[request.status];
const StatusIcon = config.icon;
const extensionDays = getExtensionDays(request);
return (
<div className="space-y-4">
<div className={cn('flex gap-3 rounded-lg border p-4', config.className)}>
<StatusIcon className="mt-0.5 size-5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-semibold">{config.title}</p>
<p className="text-xs leading-relaxed">{config.description}</p>
</div>
</div>
<section className="space-y-2">
<p className="text-xs font-semibold">Request Details</p>
<div className="divide-y overflow-hidden rounded-lg border">
<DetailRow
label="Requested On"
value={formatDate(request.requested_at)}
/>
<DetailRow
label="Requested Due Date"
value={formatDate(request.requested_due_at)}
>
{extensionDays > 0 ? (
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
+{extensionDays} {extensionDays === 1 ? 'Day' : 'Days'}
</span>
) : null}
</DetailRow>
<DetailRow label="Reason for Extension" value={request.reason} />
</div>
</section>
{request.resolved_at || request.resolved_by ? (
<section className="space-y-2">
<p className="text-xs font-semibold">Manager Response</p>
<div className="divide-y overflow-hidden rounded-lg border">
{request.resolved_at ? (
<DetailRow
label="Responded On"
value={formatDate(request.resolved_at)}
/>
) : null}
{request.resolved_by ? (
<DetailRow
label="Resolved By"
value={`${request.resolved_by.name} · ${request.resolved_by.role.name}`}
/>
) : null}
{request.status === 'approved' ? (
<DetailRow
label="Approved Due Date"
value={formatDate(request.requested_due_at)}
/>
) : null}
</div>
</section>
) : null}
<section className="space-y-2">
<p className="text-xs font-semibold">Original Due Date</p>
<div className="rounded-lg border">
<DetailRow
label="Original schedule"
value={formatDate(request.current_due_at)}
>
<CalendarDays className="size-4 text-muted-foreground" />
</DetailRow>
</div>
</section>
<Button type="button" variant="outline" className="w-full">
<History />
View Approval History
</Button>
</div>
);
}
export function TicketExtensionRequestPanel({
ticket,
}: {
ticket: TicketDetail;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
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">
<Loader2 className="size-5 animate-spin" />
<span className="ml-2 text-sm">Loading extension request...</span>
</div>
);
}
if (extensionRequestQuery.isError) {
return (
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<p className="text-sm text-destructive">
Extension request details could not be loaded.
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => extensionRequestQuery.refetch()}
>
<RotateCcw />
Try Again
</Button>
</div>
);
}
const latestRequest = extensionRequestQuery.data
? getLatestRequest(extensionRequestQuery.data)
: null;
if (!latestRequest) {
return <RequestExtensionForm ticket={ticket} />;
}
return <ExtensionRequestDetails request={latestRequest} />;
}

View File

@@ -1,121 +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 { formatRepairDate, 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 ? formatRepairDate(submittedAt) : `Image ${index + 1}`}
</p>
</ReportSurface>
);
}

View File

@@ -1,108 +0,0 @@
'use client';
import { FileText, MessageSquareQuote } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketDetail } from '@/types';
import {
EmptyPanel,
formatRepairDate,
getPersonLabel,
} 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 {formatRepairDate(repair.submitted_at)}
</span>
) : null}
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ReportField
label="Technician"
value={getPersonLabel(ticket.worker)}
/>
<ReportField
label="Submitted At"
value={
repair.submitted_at ? formatRepairDate(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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,69 +0,0 @@
'use client';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { TicketActor } from '@/types';
export function formatRepairDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
export function getPersonLabel(person: TicketActor | null | undefined) {
return person?.name || person?.email || 'Unknown';
}
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>
);
}

View File

@@ -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>
);
}

View File

@@ -6,14 +6,24 @@ import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TicketDetailHeader } from './components/TicketDetailHeader'; import { TicketDetailHeader } from './components/TicketDetailHeader';
import { TicketDetailSkeleton } from './components/TicketDetailSkeleton'; import {
TicketOverviewCardSkeleton,
TicketOverviewHeaderSkeleton,
} from './components/TicketOverviewSkeleton';
import {
TicketClassActionsSkeleton,
TicketClassContentSkeleton,
} from './components/TicketClassDetailSkeleton';
import { TicketHistoryCard } from './components/TicketHistoryCard'; import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { useTicketDetailQuery } from '../hooks/useTicketQueries'; import {
useTicketClassDetailQuery,
useTicketOverviewQuery,
} from '../hooks/useTicketQueries';
export default function TicketDetailPage() { export default function TicketDetailPage() {
const router = useRouter(); const router = useRouter();
@@ -21,16 +31,17 @@ export default function TicketDetailPage() {
const { ticketId } = useParams() as { ticketId: string }; const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined; const defectClass = searchParams.get('defect_class') ?? undefined;
const ticketQuery = useTicketDetailQuery(ticketId, defectClass); const overviewQuery = useTicketOverviewQuery(ticketId);
const ticket = ticketQuery.data; const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data;
const classDetail = classDetailQuery.data;
const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading,
);
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId)); useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
if (ticketQuery.isLoading) { if (overviewQuery.isError) {
return <TicketDetailSkeleton />;
}
if (ticketQuery.isError || !ticket) {
return ( return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3"> <div className="flex min-h-[60vh] flex-col items-center justify-center gap-3">
<p className="text-sm text-destructive">Failed to load ticket.</p> <p className="text-sm text-destructive">Failed to load ticket.</p>
@@ -47,18 +58,34 @@ export default function TicketDetailPage() {
} }
return ( return (
<main className="relative z-10 space-y-5"> <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">
<TicketDetailHeader ticket={ticket} /> <div className="xl:shrink-0">
{overview ? (
<TicketDetailHeader ticket={overview} />
) : (
<TicketOverviewHeaderSkeleton />
)}
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]"> <div className="grid 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="space-y-5"> <div className="space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<TicketOverviewCard ticket={ticket} /> {overview ? (
<TicketRepairReportCard ticket={ticket} /> <TicketOverviewCard ticket={overview} />
<TicketHistoryCard ticket={ticket} /> ) : (
<TicketOverviewCardSkeleton />
)}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} />
) : null}
</div> </div>
<div className="self-start"> <div className="space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<TicketStatusActions ticket={ticket} /> {isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} />
) : null}
</div> </div>
</div> </div>
</main> </main>

View File

@@ -30,16 +30,21 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
size: 120, size: 120,
}, },
{ {
accessorKey: 'assigned_to_name', id: 'created_by',
header: 'Assigned To',
size: 220,
cell: ({ row }) => row.original.assigned_to_name || 'N/A',
},
{
accessorKey: 'created_by_name',
header: 'Uploaded By', header: 'Uploaded By',
size: 220, size: 220,
cell: ({ row }) => row.original.created_by_name || 'N/A', cell: ({ row }) => (
<div>
<p className="font-medium">
{row.original.created_by_name || 'N/A'}
</p>
{row.original.created_by_email ? (
<p className="text-xs text-muted-foreground">
{row.original.created_by_email}
</p>
) : null}
</div>
),
}, },
{ {
accessorKey: 'updated_at', accessorKey: 'updated_at',

View File

@@ -59,7 +59,6 @@ function shouldCountCreatedTicket(
event: TicketTableStatusEvent, event: TicketTableStatusEvent,
params?: TicketListParams, params?: TicketListParams,
) { ) {
if (params?.status && event.status !== params.status) return false;
if (params?.chainage_id && event.chainage_id !== params.chainage_id) { if (params?.chainage_id && event.chainage_id !== params.chainage_id) {
return false; return false;
} }
@@ -87,7 +86,6 @@ function buildTicketListItem(
if ( if (
!event.id || !event.id ||
!event.ticket_name || !event.ticket_name ||
!event.status ||
event.detection_count === undefined || event.detection_count === undefined ||
!event.updated_at !event.updated_at
) { ) {
@@ -99,11 +97,10 @@ function buildTicketListItem(
ticket_name: event.ticket_name, ticket_name: event.ticket_name,
chainage_id: event.chainage_id ?? null, chainage_id: event.chainage_id ?? null,
chainage_name: event.chainage_name ?? null, chainage_name: event.chainage_name ?? null,
status: event.status,
default_defect_class: event.default_defect_class ?? null, default_defect_class: event.default_defect_class ?? null,
assigned_to_name: event.assigned_to_name ?? null,
detection_count: event.detection_count, detection_count: event.detection_count,
created_by_name: event.created_by_name ?? null, created_by_name: event.created_by_name ?? null,
created_by_email: event.created_by_email ?? null,
created_at: event.created_at ?? event.updated_at, created_at: event.created_at ?? event.updated_at,
updated_at: event.updated_at, updated_at: event.updated_at,
}; };
@@ -118,12 +115,11 @@ function mergeTicketListItem(
ticket_name: event.ticket_name ?? current.ticket_name, ticket_name: event.ticket_name ?? current.ticket_name,
chainage_id: event.chainage_id ?? current.chainage_id, chainage_id: event.chainage_id ?? current.chainage_id,
chainage_name: event.chainage_name ?? current.chainage_name, chainage_name: event.chainage_name ?? current.chainage_name,
status: event.status ?? current.status,
default_defect_class: default_defect_class:
event.default_defect_class ?? current.default_defect_class, event.default_defect_class ?? current.default_defect_class,
assigned_to_name: event.assigned_to_name ?? current.assigned_to_name,
detection_count: event.detection_count ?? current.detection_count, detection_count: event.detection_count ?? current.detection_count,
created_by_name: event.created_by_name ?? current.created_by_name, 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, created_at: event.created_at ?? current.created_at,
updated_at: event.updated_at ?? current.updated_at, updated_at: event.updated_at ?? current.updated_at,
}; };

View File

@@ -31,9 +31,12 @@ export function useTicketDetailEvents(
const refetchTicket = useCallback(() => { const refetchTicket = useCallback(() => {
if (!ticketId) return; if (!ticketId) return;
queryClient.refetchQueries({ queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
queryKey: ticketKeys.detail(ticketId, defectClassRef.current), if (defectClassRef.current) {
}); queryClient.refetchQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
});
}
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId), queryKey: ticketKeys.defectClasses(ticketId),
}); });
@@ -63,19 +66,11 @@ export function useTicketDetailEvents(
[refetchTicket], [refetchTicket],
); );
const shouldStop = useCallback(
(eventName: keyof typeof events, event: unknown) =>
eventName === 'ticket_status' &&
(event as TicketDetailStatusEvent).status === 'closed',
[],
);
useSseWithToken({ useSseWithToken({
enabled: Boolean(enabled && ticketId), enabled: Boolean(enabled && ticketId),
getToken, getToken,
getPath, getPath,
events, events,
reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS, reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS,
shouldStop,
}); });
} }

View File

@@ -1,19 +1,26 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ticketService } from '@/services/api'; import { detectionService, ticketService, videoService } from '@/services/api';
import type { import type {
AssignTicketPayload, AssignTicketPayload,
CloseTicketPayload, CloseTicketPayload,
ReviewRepairPayload, DiscardDetectionPayload,
SubmitRepairPayload, RequestTicketExtensionPayload,
SubmitRepairUploadPayload, ReviewDetectionRepairProofPayload,
ReviewTicketExtensionPayload,
TicketDetail, TicketDetail,
TicketListParams, TicketListParams,
TicketStatus, DetectionProofStatus,
VideoDetectionsParams,
} from '@/types'; } from '@/types';
import { ticketKeys } from '../queries/ticketKeys'; import { ticketKeys } from '../queries/ticketKeys';
@@ -53,15 +60,26 @@ export function useTicketsQuery(params: UseTicketsQueryParams) {
}); });
} }
export function useTicketDetailQuery( export function useTicketOverviewQuery(ticketId: string | undefined) {
return useQuery({
queryKey: ticketKeys.overview(ticketId ?? ''),
queryFn: () => ticketService.getTicketOverview(ticketId as string),
enabled: Boolean(ticketId),
});
}
export function useTicketClassDetailQuery(
ticketId: string | undefined, ticketId: string | undefined,
defectClass: string | undefined, defectClass: string | undefined,
) { ) {
return useQuery({ return useQuery({
queryKey: ticketKeys.detail(ticketId ?? '', defectClass), queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''),
queryFn: () => queryFn: () =>
ticketService.getTicketDetail(ticketId as string, defectClass), ticketService.getTicketClassDetail(
enabled: Boolean(ticketId), ticketId as string,
defectClass as string,
),
enabled: Boolean(ticketId && defectClass),
}); });
} }
@@ -73,6 +91,170 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) {
}); });
} }
export function useTicketClassDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
const queryParams = useMemo(
() => ({
skip: params.skip ?? 0,
limit: params.limit ?? 1,
class_name: params.class_name,
min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc',
search: params.search,
}),
[
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(videoId && queryParams.class_name),
});
}
export function useTicketClassReviewDetectionsQuery(
videoId: string | undefined,
defectClass: string | undefined,
proofStatus?: DetectionProofStatus,
) {
const pageSize = 20;
return useInfiniteQuery({
queryKey: ticketKeys.classReviewDetections(
videoId ?? '',
defectClass ?? '',
proofStatus,
),
queryFn: ({ pageParam }) =>
videoService.getVideoDetections(videoId as string, {
class_name: defectClass,
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 && defectClass),
});
}
export function useDiscardDetectionMutation(
ticketId: string,
videoId: string | undefined,
defectClass: string,
) {
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.classDetail(ticketId, defectClass),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
]);
},
onError: () => toast.error('Failed to discard detection'),
});
}
export function useReviewDetectionRepairProofMutation({
ticketId,
videoId,
defectClass,
}: {
ticketId: string;
videoId: string | undefined;
defectClass: string;
}) {
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.classDetail(ticketId, defectClass),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
]);
},
onError: () => toast.error('Failed to review repair proof'),
});
}
export function useTicketExtensionRequestQuery(
ticketId: string | undefined,
assignmentId: number | undefined,
) {
return useQuery({
queryKey: ticketKeys.extensionRequest(ticketId ?? '', assignmentId),
queryFn: () =>
ticketService.getTicketExtensionRequest(
ticketId as string,
assignmentId as number,
),
enabled: Boolean(ticketId && assignmentId),
});
}
export function useAssignableTicketUsersQuery(enabled: boolean) { export function useAssignableTicketUsersQuery(enabled: boolean) {
return useQuery({ return useQuery({
queryKey: ticketKeys.assignableUsers(), queryKey: ticketKeys.assignableUsers(),
@@ -103,7 +285,7 @@ export function useAssignTicketMutation(ticketId: string) {
onSuccess: (ticket, payload) => { onSuccess: (ticket, payload) => {
toast.success('Ticket assigned'); toast.success('Ticket assigned');
queryClient.setQueryData<TicketDetail>( queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId, payload.defect_class), ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket), (current) => mergeTicketDetailResponse(current, ticket),
); );
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
@@ -112,62 +294,64 @@ export function useAssignTicketMutation(ticketId: string) {
}); });
} }
export function useSubmitRepairMutation(ticketId: string) { export function useRequestTicketExtensionMutation(
ticketId: string,
assignmentId: number | undefined,
defectClass: string,
) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (payload: SubmitRepairPayload) => mutationFn: (payload: RequestTicketExtensionPayload) => {
ticketService.submitRepair(ticketId, payload), if (assignmentId === undefined) {
onSuccess: (ticket) => { throw new Error('No active assignment is available for this ticket.');
toast.success('Repair submitted'); }
queryClient.setQueryData<TicketDetail>( return ticketService.requestTicketExtension(
ticketKeys.detail(ticketId), ticketId,
(current) => mergeTicketDetailResponse(current, ticket), assignmentId,
payload,
); );
},
onSuccess: () => {
toast.success('Extension request submitted');
queryClient.invalidateQueries({
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to submit repair'), onError: () => toast.error('Failed to request an extension'),
}); });
} }
export function useReviewTicketExtensionMutation(
export function useSubmitRepairUploadMutation(ticketId: string) { ticketId: string,
assignmentId: number,
defectClass: string,
) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (payload: SubmitRepairUploadPayload) => mutationFn: (payload: ReviewTicketExtensionPayload) =>
ticketService.submitRepairUpload(ticketId, payload), ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
onSuccess: (ticket, payload) => { onSuccess: (_response, payload) => {
toast.success('Repair evidence uploaded');
queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to upload repair evidence'),
});
}
export function useReviewRepairMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ReviewRepairPayload) =>
ticketService.reviewRepair(ticketId, payload),
onSuccess: (ticket, payload) => {
toast.success( toast.success(
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected', payload.action === 'approve'
); ? 'Extension request approved'
queryClient.setQueryData<TicketDetail>( : 'Extension request rejected',
ticketKeys.detail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
); );
queryClient.invalidateQueries({
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to review repair'), onError: () => toast.error('Failed to review extension request'),
}); });
} }
export function useCloseTicketMutation(ticketId: string) { export function useCloseTicketMutation(ticketId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -177,7 +361,7 @@ export function useCloseTicketMutation(ticketId: string) {
onSuccess: (ticket, payload) => { onSuccess: (ticket, payload) => {
toast.success('Ticket closed'); toast.success('Ticket closed');
queryClient.setQueryData<TicketDetail>( queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId, payload.defect_class), ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket), (current) => mergeTicketDetailResponse(current, ticket),
); );
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });

View File

@@ -1,13 +1,38 @@
import type { TicketListParams } from '@/types'; import type { TicketListParams, VideoDetectionsParams } from '@/types';
export const ticketKeys = { export const ticketKeys = {
all: ['tickets'] as const, all: ['tickets'] as const,
lists: () => [...ticketKeys.all, 'list'] as const, lists: () => [...ticketKeys.all, 'list'] as const,
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const, list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
details: () => [...ticketKeys.all, 'detail'] as const, details: () => [...ticketKeys.all, 'detail'] as const,
detail: (ticketId: string, defectClass?: string) => overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, defectClass] as const, [...ticketKeys.details(), ticketId, 'overview'] as const,
classDetail: (ticketId: string, defectClass: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
classDetectionLists: (videoId: string) =>
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...ticketKeys.classDetectionLists(videoId), params] as const,
classReviewDetections: (
videoId: string,
defectClass: string,
proofStatus?: string,
) =>
[
...ticketKeys.classDetectionLists(videoId),
'class-review',
defectClass,
proofStatus ?? 'all',
] as const,
defectClasses: (ticketId: string) => defectClasses: (ticketId: string) =>
[...ticketKeys.detail(ticketId), 'defect-classes'] as const, [...ticketKeys.details(), ticketId, 'defect-classes'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
[
...ticketKeys.details(),
ticketId,
'assignments',
assignmentId,
'extension-request',
] as const,
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const, assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
}; };

View File

@@ -1,79 +0,0 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const pageSizes = [10, 20, 40, 50, 100];
interface CardPaginationProps {
skip: number;
limit: number;
total: number;
count: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
}
export function CardPagination({
skip,
limit,
total,
count,
onPageChange,
onLimitChange,
}: CardPaginationProps) {
return (
<div className="flex flex-col gap-3 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-3">
<span>Showing {count} of {total}</span>
<div className="flex items-center gap-2">
<span>Rows per page</span>
<Select
value={String(limit)}
onValueChange={(value) => onLimitChange(Number(value))}
>
<SelectTrigger className="h-8 w-18 bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizes.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={skip === 0}
onClick={() => onPageChange(Math.max(0, skip - limit))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={skip + limit >= total}
onClick={() => onPageChange(skip + limit)}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { Loader2, Play } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -89,19 +90,17 @@ export function CreateUploadDialog({
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent <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()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>Upload Video</DialogTitle>
Upload Video
</DialogTitle>
<DialogDescription> <DialogDescription>
Configure location and upload road inspection data. Configure location and upload road inspection data.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-4"> <DialogBody className="space-y-5">
<LocationSection <LocationSection
value={session} value={session}
onChange={setSession} onChange={setSession}
@@ -130,13 +129,13 @@ export function CreateUploadDialog({
portalContainer={dropdownPortalRef} portalContainer={dropdownPortalRef}
/> />
{error ? ( {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} {error}
</div> </div>
) : null} ) : null}
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -1,63 +0,0 @@
'use client';
import { Eye, MoreVertical, PlayCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { UploadVideo } from '@/types';
interface UploadActionsMenuProps {
upload: UploadVideo;
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void;
}
export function UploadActionsMenu({
upload,
onViewVideo,
onViewDetails,
}: UploadActionsMenuProps) {
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.video_url ? (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onViewVideo(upload);
}}
>
<PlayCircle className="size-4" />
View Video
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onViewDetails(upload);
}}
>
<Eye className="size-4" />
View Details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,107 +0,0 @@
'use client';
import { FileVideo } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import type { UploadVideo } from '@/types';
import { CardPagination } from './CardPagination';
import { UploadActionsMenu } from './UploadActionsMenu';
import { UploadThumbnail } from './UploadThumbnail';
import { formatUploadedAt } from './uploadUtils';
interface UploadCardGridProps {
uploads: UploadVideo[];
isLoading: boolean;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onUploadNew: () => void;
onOpenUpload: (upload: UploadVideo) => void;
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void;
}
export function UploadCardGrid({
uploads,
isLoading,
skip,
limit,
total,
onPageChange,
onLimitChange,
onUploadNew,
onOpenUpload,
onViewVideo,
onViewDetails,
}: UploadCardGridProps) {
const skeletonItems = Array.from({ length: 6 });
if (!isLoading && uploads.length === 0) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<div className="flex size-12 items-center justify-center rounded-md border bg-muted/40">
<FileVideo className="size-6 text-muted-foreground" />
</div>
<div>
<h2 className="text-base">No uploads found</h2>
<p className="text-sm text-muted-foreground">
Upload your first road inspection video.
</p>
</div>
<Button onClick={onUploadNew}>Upload New</Button>
</CardContent>
</Card>
);
}
return (
<section className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
{isLoading
? skeletonItems.map((_, index) => (
<Card key={index} className="h-62 animate-pulse bg-muted/30" />
))
: uploads.map((upload) => (
<Card
key={upload.video_id}
className="cursor-pointer overflow-hidden transition-colors hover:bg-muted/30"
onClick={() => onOpenUpload(upload)}
>
<CardContent className="space-y-3 p-3">
<UploadThumbnail upload={upload} />
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{upload.filename}
</p>
<p className="text-xs text-muted-foreground">
Uploaded {formatUploadedAt(upload.uploaded_at)}
</p>
</div>
<UploadActionsMenu
upload={upload}
onViewVideo={onViewVideo}
onViewDetails={onViewDetails}
/>
</div>
</CardContent>
</Card>
))}
</div>
<CardPagination
skip={skip}
limit={limit}
total={total}
count={uploads.length}
onPageChange={onPageChange}
onLimitChange={onLimitChange}
/>
</section>
);
}

View File

@@ -1,77 +0,0 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { UploadActionsMenu } from './UploadActionsMenu';
import { UploadThumbnail } from './UploadThumbnail';
import { formatUploadedAt, getUploadStatus } from './uploadUtils';
import { Badge } from '@/components/ui/badge';
import type { UploadVideo } from '@/types';
interface UseUploadColumnsProps {
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void;
}
export function useUploadColumns({
onViewVideo,
onViewDetails,
}: UseUploadColumnsProps) {
return useMemo<ColumnDef<UploadVideo>[]>(
() => [
{
id: 'thumbnail',
header: 'Thumbnail',
size: 140,
minSize: 120,
enableSorting: false,
meta: { disableTruncate: true },
cell: ({ row }) => (
<UploadThumbnail upload={row.original} className="w-28" />
),
},
{
accessorKey: 'filename',
header: 'Filename',
cell: ({ row }) => (
<span className="font-medium">{row.original.filename}</span>
),
},
{
id: 'status',
header: 'Status',
size: 130,
cell: ({ row }) => (
<Badge className="rounded-full">
{getUploadStatus(row.original)}
</Badge>
),
},
{
accessorKey: 'uploaded_at',
header: 'Uploaded At',
cell: ({ row }) => formatUploadedAt(row.original.uploaded_at),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 56,
minSize: 56,
enableSorting: false,
enableHiding: false,
meta: { disableTruncate: true },
cell: ({ row }) => (
<div className="flex justify-center">
<UploadActionsMenu
upload={row.original}
onViewVideo={onViewVideo}
onViewDetails={onViewDetails}
/>
</div>
),
},
],
[onViewDetails, onViewVideo],
);
}

View File

@@ -1,25 +1,31 @@
'use client'; '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 { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge'; import { PERMISSIONS } from '@/constants/permissions';
import { import { usePermissions } from '@/hooks/usePermissions';
Tooltip, import { ROUTES } from '@/utils/routes';
TooltipContent, import type { UploadListItem } from '@/types';
TooltipProvider, import { formatDate } from '@/utils/date';
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { UploadVideo } from '@/types';
import { formatUploadedAt, getUploadStatus } from './uploadUtils'; import { UploadResultCell } from './UploadResultCell';
import { UploadStatusBadge } from './UploadStatusBadge';
interface UploadDetailsDialogProps { interface UploadDetailsDialogProps {
upload: UploadVideo | null; upload: UploadListItem | null;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@@ -29,50 +35,108 @@ export function UploadDetailsDialog({
open, open,
onOpenChange, onOpenChange,
}: UploadDetailsDialogProps) { }: UploadDetailsDialogProps) {
const title = upload?.filename ?? 'Upload Details'; 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-6xl">
<DialogHeader className="min-w-0 pr-8"> <DialogHeader>
<DialogTitle className="min-w-0 max-w-full overflow-hidden text-left"> <DialogTitle>Upload Details</DialogTitle>
{upload?.filename ? ( <DialogDescription>
<TooltipProvider delayDuration={200}> Preview the inspection video and review its processing details.
<Tooltip> </DialogDescription>
<TooltipTrigger asChild>
<span
className="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
title={title}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 break-all">
{title}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
title
)}
</DialogTitle>
<DialogDescription>Uploaded video details</DialogDescription>
</DialogHeader> </DialogHeader>
{upload ? ( <DialogBody>
<dl className="grid grid-cols-[120px_1fr] gap-x-4 gap-y-3 text-sm"> <div className="grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,0.8fr)]">
<dt className="text-muted-foreground">Status</dt> <section className="min-w-0 space-y-2">
<dd> <p className="text-sm font-medium">Video Preview</p>
<Badge className="rounded-full">{getUploadStatus(upload)}</Badge> <ProtectedVideoPlayer
</dd> url={open ? upload?.raw_video_url : undefined}
<dt className="text-muted-foreground">Uploaded At</dt> label={upload?.video_name}
<dd>{formatUploadedAt(upload.uploaded_at)}</dd> className="rounded-lg"
<dt className="text-muted-foreground">Uploaded By</dt> unavailableTitle="Video preview unavailable"
<dd>{upload.uploaded_by_email ?? '-'}</dd> />
<dt className="text-muted-foreground">Video ID</dt> </section>
<dd className="break-all font-mono text-xs">{upload.video_id}</dd>
</dl> <aside className="min-w-0 space-y-4 rounded-lg border bg-muted/15 p-4">
) : null} <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> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -56,7 +56,7 @@ export function UploadFileDropzone({
handleFiles(event.dataTransfer.files); handleFiles(event.dataTransfer.files);
}} }}
className={cn( 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', 'hover:border-primary/60 hover:bg-secondary/40',
isDragging && 'border-primary bg-secondary/60', isDragging && 'border-primary bg-secondary/60',
disabled && 'pointer-events-none cursor-not-allowed opacity-60', disabled && 'pointer-events-none cursor-not-allowed opacity-60',
@@ -72,7 +72,7 @@ export function UploadFileDropzone({
/> />
<div className="space-y-3"> <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" /> <UploadCloud className="size-4 text-muted-foreground" />
</div> </div>
<div> <div>

View File

@@ -0,0 +1,136 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Video } from 'lucide-react';
import { ENV_CONSTANT } from '@/constants/secrect.constant';
import { Badge } from '@/components/ui/badge';
import type { UploadListItem } from '@/types';
import { formatDate } from '@/utils/date';
import { UploadResultCell } from './UploadResultCell';
import { UploadStatusBadge } from './UploadStatusBadge';
function resolveThumbnailUrl(value: string | null) {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
const baseUrl = ENV_CONSTANT.BASE_API_URL?.replace(/\/$/, '');
const path = value.startsWith('/') ? value : `/${value}`;
return baseUrl ? `${baseUrl}${path}` : path;
}
export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
return useMemo(
() => [
{
id: 'thumbnail',
header: 'Thumbnail',
size: 104,
minSize: 96,
enableSorting: false,
meta: { disableTruncate: true },
cell: ({ row }) => {
const thumbnail = resolveThumbnailUrl(row.original.thumbnail);
return (
<div
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 ? 'Upload thumbnail' : undefined}
>
{!thumbnail ? (
<Video className="size-5 text-muted-foreground" />
) : null}
</div>
);
},
},
{
id: 'uploaded_by',
header: 'Uploader',
size: 190,
cell: ({ row }) => (
<div>
<p className="font-medium">
{row.original.uploaded_by.name ?? '—'}
</p>
<p className="text-xs text-muted-foreground">
{row.original.uploaded_by.email ?? '—'}
</p>
</div>
),
},
{
accessorKey: 'project',
header: 'Project',
size: 165,
cell: ({ row }) => row.original.project ?? '—',
},
{
accessorKey: 'package',
header: 'Package',
size: 110,
cell: ({ row }) => row.original.package ?? '—',
},
{
accessorKey: 'segment',
header: 'Segment',
size: 140,
cell: ({ row }) => row.original.segment ?? '—',
},
{
accessorKey: 'chainage',
header: 'Chainage',
size: 155,
cell: ({ row }) => row.original.chainage ?? '—',
},
{
accessorKey: 'uploaded_at',
header: 'Uploaded At',
size: 175,
cell: ({ row }) => formatDate(row.original.uploaded_at),
},
{
accessorKey: 'is_uploaded',
header: 'Upload Status',
size: 125,
meta: { disableTruncate: true },
cell: ({ row }) =>
row.original.is_uploaded ? (
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
Uploaded
</Badge>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'status',
header: 'Analysis Status',
size: 145,
meta: { disableTruncate: true },
cell: ({ row }) => (
<UploadStatusBadge
status={row.original.status}
progress={row.original.progress}
/>
),
},
{
id: 'result',
header: 'Result',
size: 170,
meta: { disableTruncate: true },
cell: ({ row }) => <UploadResultCell item={row.original} />,
},
],
[],
);
}

View File

@@ -0,0 +1,46 @@
import { CircleMinus, CircleX, TicketCheck } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { UploadListItem } from '@/types';
export function UploadResultCell({ item }: { item: UploadListItem }) {
if (item.result === 'ticket_created') {
return (
<div className="space-y-1">
<Badge className="bg-violet-500/15 text-violet-600 dark:text-violet-400">
<TicketCheck /> Ticket Created
</Badge>
<p className="text-xs font-medium">
{item.ticket_name ?? item.ticket_id}
</p>
</div>
);
}
if (item.result === 'analysis_failed') {
return (
<div className="space-y-1 text-red-600 dark:text-red-400">
<div className="flex items-center gap-1.5 text-xs font-medium">
<CircleX className="size-3.5" /> Analysis Failed
</div>
<p className="text-xs text-muted-foreground">
{item.error_message ?? 'Processing failed'}
</p>
</div>
);
}
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>;
}

View File

@@ -0,0 +1,65 @@
import { CheckCircle2, Clock3, LoaderCircle, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import type { UploadStatus } from '@/types';
interface UploadStatusBadgeProps {
status: UploadStatus;
progress: number;
}
const statusConfig = {
queued: {
label: 'Queued',
icon: Clock3,
className: 'bg-zinc-500/15 text-zinc-500 dark:text-zinc-300',
},
processing: {
label: 'Processing',
icon: LoaderCircle,
className: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
},
completed: {
label: 'Completed',
icon: CheckCircle2,
className: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
},
failed: {
label: 'Failed',
icon: XCircle,
className: 'bg-red-500/15 text-red-600 dark:text-red-400',
},
} satisfies Record<
UploadStatus,
{ label: string; icon: typeof Clock3; className: string }
>;
export function UploadStatusBadge({
status,
progress,
}: UploadStatusBadgeProps) {
const config = statusConfig[status];
const Icon = config.icon;
return (
<div className="space-y-1.5">
<Badge variant="secondary" className={config.className}>
<Icon
className={status === 'processing' ? 'animate-spin' : undefined}
/>
{config.label}
</Badge>
{status === 'processing' ? (
<div className="w-24 space-y-1">
<span className="text-xs text-muted-foreground">
{progress}% complete
</span>
<Progress value={progress} className="h-1.5" />
</div>
) : status === 'completed' ? (
<p className="text-xs text-muted-foreground">100%</p>
) : null}
</div>
);
}

View File

@@ -3,18 +3,18 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { UploadVideo } from '@/types'; import type { UploadListItem } from '@/types';
interface UploadTableProps { interface UploadTableProps {
columns: ColumnDef<UploadVideo>[]; columns: ColumnDef<UploadListItem>[];
uploads: UploadVideo[]; uploads: UploadListItem[];
isLoading: boolean; isLoading: boolean;
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;
onRowClick: (upload: UploadVideo) => void;
} }
export function UploadTable({ export function UploadTable({
@@ -24,9 +24,9 @@ export function UploadTable({
skip, skip,
limit, limit,
total, total,
onUploadClick,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onRowClick,
}: UploadTableProps) { }: UploadTableProps) {
return ( return (
<DataTable <DataTable
@@ -35,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,
@@ -42,7 +43,6 @@ export function UploadTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
onRowClick={onRowClick}
enableColumnControls={false} enableColumnControls={false}
minColumnSize={120} minColumnSize={120}
/> />

View File

@@ -1,33 +0,0 @@
import { ImageIcon } from 'lucide-react';
import type { UploadVideo } from '@/types';
import { resolveMediaUrl } from './uploadUtils';
interface UploadThumbnailProps {
upload: UploadVideo;
className?: string;
}
export function UploadThumbnail({ upload, className }: UploadThumbnailProps) {
const thumbnailUrl = resolveMediaUrl(upload.thumbnail);
return (
<div className={className}>
<div className="aspect-video overflow-hidden rounded-md border bg-muted/40">
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={upload.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageIcon className="size-8" />
</div>
)}
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
'use client';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { UploadVideo } from '@/types';
interface UploadVideoDialogProps {
upload: UploadVideo | 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?.filename ?? 'Uploaded video'}
</DialogDescription>
</DialogHeader>
<ProtectedVideoPlayer
url={open ? upload?.video_url : undefined}
className="rounded-lg"
unavailableTitle="Video preview unavailable"
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,46 +0,0 @@
'use client';
import { Grid2X2, Table2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { UploadViewMode } from '../hooks/useUploadFilters';
interface ViewSwitcherProps {
value: UploadViewMode;
onValueChange: (value: UploadViewMode) => void;
}
export function ViewSwitcher({ value, onValueChange }: ViewSwitcherProps) {
const items: Array<{
value: UploadViewMode;
label: string;
icon: React.ReactNode;
}> = [
{ value: 'card', label: 'Card View', icon: <Grid2X2 className="size-4" /> },
{ value: 'table', label: 'Table View', icon: <Table2 className="size-4" /> },
];
return (
<div className="inline-flex rounded-md border bg-card p-1">
{items.map((item) => (
<Button
key={item.value}
type="button"
variant="ghost"
size="sm"
onClick={() => onValueChange(item.value)}
className={cn(
'gap-2',
value === item.value && 'bg-secondary text-foreground',
)}
>
{item.icon}
{item.label}
</Button>
))}
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { ENV_CONSTANT } from '@/constants/secrect.constant';
import type { UploadVideo } from '@/types';
export function getUploadStatus(upload: UploadVideo) {
return upload.status?.status || 'unknown';
}
export function formatUploadedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(date);
}
export function resolveMediaUrl(url?: string | null) {
if (!url) return null;
if (/^https?:\/\//i.test(url)) return url;
const baseUrl = ENV_CONSTANT.BASE_API_URL?.replace(/\/$/, '');
const path = url.startsWith('/') ? url : `/${url}`;
return baseUrl ? `${baseUrl}${path}` : path;
}

View File

@@ -2,14 +2,11 @@
import { useState } from 'react'; import { useState } from 'react';
export type UploadViewMode = 'card' | 'table';
const DEFAULT_LIMIT = 20; const DEFAULT_LIMIT = 20;
export function useUploadFilters() { export function useUploadFilters() {
const [skip, setSkip] = useState(0); const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(DEFAULT_LIMIT); const [limit, setLimitValue] = useState(DEFAULT_LIMIT);
const [viewMode, setViewMode] = useState<UploadViewMode>('card');
const setLimit = (nextLimit: number) => { const setLimit = (nextLimit: number) => {
setLimitValue(nextLimit); setLimitValue(nextLimit);
@@ -21,7 +18,5 @@ export function useUploadFilters() {
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
viewMode,
setViewMode,
}; };
} }

View File

@@ -0,0 +1,112 @@
'use client';
import { useCallback, useMemo, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { API_ROUTES } from '@/constants/apiRoutes';
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
import { videoService } from '@/services/api';
import type {
PaginationParams,
UploadListResponse,
UploadStatusEvent,
} from '@/types';
import { uploadKeys } from './useUploadQueries';
function getUploadListParams(queryKey: readonly unknown[]) {
const params = queryKey[2];
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return undefined;
}
return params as PaginationParams;
}
function patchUploadList(
current: UploadListResponse | undefined,
event: UploadStatusEvent,
params?: PaginationParams,
) {
if (!current) return current;
const existingIndex = current.items.findIndex(
(upload) => upload.id === event.item.id,
);
if (existingIndex >= 0) {
return {
...current,
items: current.items.map((upload) =>
upload.id === event.item.id ? event.item : upload,
),
};
}
if (event.kind !== 'created') return current;
const nextTotal = current.total + 1;
if ((params?.skip ?? 0) !== 0) {
return { ...current, total: nextTotal };
}
const pageLimit = params?.limit ?? current.items.length + 1;
return {
items: [event.item, ...current.items].slice(0, pageLimit),
total: nextTotal,
};
}
export function useUploadListEvents(enabled = true) {
const queryClient = useQueryClient();
const hasInvalidatedAfterConnectionErrorRef = useRef(false);
const events = useMemo(
() => ({
upload_status: (event: UploadStatusEvent) => {
queryClient
.getQueryCache()
.findAll({ queryKey: uploadKeys.lists() })
.forEach((query) => {
queryClient.setQueryData<UploadListResponse>(
query.queryKey,
(current) =>
patchUploadList(
current,
event,
getUploadListParams(query.queryKey),
),
);
});
},
heartbeat: () => undefined,
}),
[queryClient],
);
const getPath = useCallback(
(token: string) => API_ROUTES.VIDEOS.UPLOAD_EVENTS(token),
[],
);
const onConnectionError = useCallback(() => {
if (hasInvalidatedAfterConnectionErrorRef.current) return;
hasInvalidatedAfterConnectionErrorRef.current = true;
queryClient.invalidateQueries({ queryKey: uploadKeys.lists() });
}, [queryClient]);
const onConnectionOpen = useCallback(() => {
hasInvalidatedAfterConnectionErrorRef.current = false;
}, []);
useSseWithToken({
enabled,
getToken: videoService.createUploadEventsToken,
getPath,
events,
onConnectionError,
onConnectionOpen,
});
}

View File

@@ -13,7 +13,7 @@ export const uploadKeys = {
list: (params: PaginationParams) => [...uploadKeys.lists(), params] as const, list: (params: PaginationParams) => [...uploadKeys.lists(), params] as const,
}; };
interface UseMyUploadsQueryParams { interface UseUploadsQueryParams {
skip: number; skip: number;
limit: number; limit: number;
} }
@@ -21,14 +21,14 @@ interface UseMyUploadsQueryParams {
function buildUploadListParams({ function buildUploadListParams({
skip, skip,
limit, limit,
}: UseMyUploadsQueryParams): PaginationParams { }: UseUploadsQueryParams): PaginationParams {
return { return {
skip, skip,
limit, limit,
}; };
} }
export function useMyUploadsQuery(params: UseMyUploadsQueryParams) { export function useUploadsQuery(params: UseUploadsQueryParams) {
const { skip, limit } = params; const { skip, limit } = params;
const listParams = useMemo( const listParams = useMemo(
() => buildUploadListParams({ skip, limit }), () => buildUploadListParams({ skip, limit }),
@@ -37,7 +37,7 @@ export function useMyUploadsQuery(params: UseMyUploadsQueryParams) {
return useQuery({ return useQuery({
queryKey: uploadKeys.list(listParams), queryKey: uploadKeys.list(listParams),
queryFn: () => videoService.getMyUploads(listParams), queryFn: () => videoService.getUploads(listParams),
}); });
} }

View File

@@ -1,62 +1,41 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useState } from 'react';
import { Plus, UploadCloud } from 'lucide-react'; import { Plus, UploadCloud } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { UploadVideo } from '@/types'; import type { UploadListItem } from '@/types';
import { CreateUploadDialog } from './components/CreateUploadDialog'; import { CreateUploadDialog } from './components/CreateUploadDialog';
import { UploadCardGrid } from './components/UploadCardGrid'; import { useUploadListColumns } from './components/UploadListColumns';
import { useUploadColumns } from './components/UploadColumns';
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
import { UploadTable } from './components/UploadTable'; import { UploadTable } from './components/UploadTable';
import { UploadVideoDialog } from './components/UploadVideoDialog'; import { UploadDetailsDialog } from './components/UploadDetailsDialog';
import { ViewSwitcher } from './components/ViewSwitcher';
import { useUploadFilters } from './hooks/useUploadFilters'; import { useUploadFilters } from './hooks/useUploadFilters';
import { useMyUploadsQuery } from './hooks/useUploadQueries'; import { useUploadListEvents } from './hooks/useUploadListEvents';
import { useUploadsQuery } from './hooks/useUploadQueries';
export default function UploadPage() { export default function UploadPage() {
const { skip, setSkip, limit, setLimit, viewMode, setViewMode } = const { skip, setSkip, limit, setLimit } = useUploadFilters();
useUploadFilters(); const uploadsQuery = useUploadsQuery({ skip, limit });
const uploadsQuery = useMyUploadsQuery({ skip, limit }); useUploadListEvents();
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [detailsUpload, setDetailsUpload] = useState<UploadVideo | null>(null); const [selectedUpload, setSelectedUpload] = useState<UploadListItem | null>(
const [videoUpload, setVideoUpload] = useState<UploadVideo | null>(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 handleViewDetails = useCallback((upload: UploadVideo) => { const columns = useUploadListColumns();
setDetailsUpload(upload);
}, []);
const handleViewUploadVideo = useCallback((upload: UploadVideo) => {
setVideoUpload(upload);
}, []);
const handleOpenUpload = useCallback(
(upload: UploadVideo) => {
if (upload.video_url) {
handleViewUploadVideo(upload);
}
},
[handleViewUploadVideo],
);
const columns = useUploadColumns({
onViewVideo: handleViewUploadVideo,
onViewDetails: handleViewDetails,
});
return ( return (
<> <>
<main className="space-y-5"> <main className="space-y-5">
<PageHeader <PageHeader
title="My Uploads" title="Uploads"
description="View and manage uploaded road inspection videos." description="Track uploaded road inspection videos, processing progress, and results."
icon={UploadCloud} icon={UploadCloud}
actions={ actions={
<Button onClick={() => setIsCreateOpen(true)} size="sm"> <Button onClick={() => setIsCreateOpen(true)} size="sm">
@@ -66,52 +45,25 @@ export default function UploadPage() {
} }
/> />
<div className="flex justify-end"> <UploadTable
<ViewSwitcher value={viewMode} onValueChange={setViewMode} /> columns={columns}
</div> uploads={uploads}
isLoading={isLoading}
{viewMode === 'card' ? ( skip={skip}
<UploadCardGrid limit={limit}
uploads={uploads} total={total}
isLoading={isLoading} onUploadClick={setSelectedUpload}
skip={skip} onPageChange={setSkip}
limit={limit} onLimitChange={setLimit}
total={total} />
onPageChange={setSkip}
onLimitChange={setLimit}
onUploadNew={() => setIsCreateOpen(true)}
onOpenUpload={handleOpenUpload}
onViewVideo={handleViewUploadVideo}
onViewDetails={handleViewDetails}
/>
) : (
<UploadTable
columns={columns}
uploads={uploads}
isLoading={isLoading}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onRowClick={handleOpenUpload}
/>
)}
</main> </main>
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} /> <CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
<UploadDetailsDialog <UploadDetailsDialog
upload={detailsUpload} upload={selectedUpload}
open={Boolean(detailsUpload)} open={Boolean(selectedUpload)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setDetailsUpload(null); if (!open) setSelectedUpload(null);
}}
/>
<UploadVideoDialog
upload={videoUpload}
open={Boolean(videoUpload)}
onOpenChange={(open) => {
if (!open) setVideoUpload(null);
}} }}
/> />
</> </>

View File

@@ -14,6 +14,7 @@ import { RoleSelect } from '@/components/lookups/RoleSelect';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -67,13 +68,13 @@ export function UserSheet({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-xl"> <DialogContent className="sm:max-w-xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>
{userId ? 'Edit User' : 'Create User'} {userId ? 'Edit User' : 'Create User'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
Assign user details and a role. Assign user details and a role.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -83,7 +84,7 @@ export function UserSheet({
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col" 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="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
id="first-name" id="first-name"
@@ -169,12 +170,12 @@ export function UserSheet({
type="hidden" type="hidden"
{...register('role_id')} {...register('role_id')}
value={roleId} value={roleId}
readOnly readOnly
/> />
</FormField> </FormField>
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -1,84 +1,86 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './typography.css'; @import './typography.css';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--radius: 0.65rem; --radius: 0.225rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.541 0.281 293.009); --primary: oklch(0.457 0.24 277.023);
--primary-foreground: oklch(0.969 0.016 293.756); --primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.967 0.001 286.375); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.552 0.016 285.938); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.967 0.001 286.375); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.21 0.006 285.885); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32); --border: oklch(0.922 0 0);
--input: oklch(0.92 0.004 286.32); --input: oklch(0.922 0 0);
--ring: oklch(0.702 0.183 293.541); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.65 0.18 25); /* Soft Red/Rose - High Priority */ --chart-1: oklch(0.785 0.115 274.713); /* Soft Red/Rose - High Priority */
--chart-2: oklch(0.78 0.12 75); /* Warm Amber - Distinct */ --chart-2: oklch(0.585 0.233 277.117); /* Warm Amber - Distinct */
--chart-3: oklch(0.68 0.12 245); /* Azure Blue - Cool Professional */ --chart-3: oklch(0.511 0.262 276.966); /* Azure Blue - Cool Professional */
--chart-4: oklch(0.75 0.1 165); /* Mint Teal - Balanced */ --chart-4: oklch(0.457 0.24 277.023); /* Mint Teal - Balanced */
--chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */ --chart-5: oklch(0.398 0.195 277.366); /* Soft Cyan - Subdued */
--chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */ --chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */
--chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */ --chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.541 0.281 293.009); --sidebar-primary: oklch(0.511 0.262 276.966);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756); --sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.967 0.001 286.375); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.702 0.183 293.541); --sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
} }
.dark { .dark {
--background: oklch(0.141 0.005 285.823); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.606 0.25 292.717); --primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.969 0.016 293.756); --primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.705 0.015 286.067); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.274 0.006 286.033); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.38 0.189 293.745); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.7 0.16 25); --chart-1: oklch(0.785 0.115 274.713);
--chart-2: oklch(0.82 0.14 75); --chart-2: oklch(0.585 0.233 277.117);
--chart-3: oklch(0.72 0.14 245); --chart-3: oklch(0.511 0.262 276.966);
--chart-4: oklch(0.78 0.12 165); --chart-4: oklch(0.457 0.24 277.023);
--chart-5: oklch(0.88 0.1 195); --chart-5: oklch(0.398 0.195 277.366);
--chart-6: oklch(0.65 0.24 295); --chart-6: oklch(0.65 0.24 295);
--chart-8: oklch(0.62 0.22 335); --chart-8: oklch(0.62 0.22 335);
--sidebar: oklch(0.21 0.006 285.885); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.606 0.25 292.717); --sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756); --sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.38 0.189 293.745); --sidebar-ring: oklch(0.556 0 0);
} }
@theme inline { @theme inline {
--font-sans: 'Poppins', 'Poppins Fallback', system-ui, sans-serif; --font-sans: var(--font-sans);
--font-mono: 'Geist Mono', 'Geist Mono Fallback', monospace; --font-mono: 'Geist Mono', 'Geist Mono Fallback', monospace;
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
@@ -119,14 +121,47 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--font-heading: var(--font-sans);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
} }
@layer base { @layer base {
html:has(body[data-scroll-locked]) {
scrollbar-gutter: auto;
}
@supports not (scrollbar-gutter: stable) {
.scroll-stable {
overflow-y: scroll;
}
}
* { * {
@apply border-border outline-none; @apply outline-none border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
.scroll-stable {
scrollbar-gutter: stable;
}
html {
@apply font-sans;
}
button:not(:disabled), [role="button"]:not(:disabled) {
cursor: pointer;
}
}
.detection-video-player .vds-slider-preview {
display: none;
}
.detection-video-player
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
display: flex;
} }

View File

@@ -1,10 +1,20 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Geist_Mono, Poppins } from 'next/font/google'; import { Geist_Mono, Poppins, Inter } from 'next/font/google';
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
import 'leaflet/dist/leaflet.css';
import 'lightgallery/css/lightgallery.css';
import 'lightgallery/css/lg-thumbnail.css';
import 'lightgallery/css/lg-zoom.css';
import './globals.css'; import './globals.css';
import { ThemeProvider } from '@/providers/ThemeProvider'; import { ThemeProvider } from '@/providers/ThemeProvider';
import AppInitializer from '@/providers/AppInitializer'; import AppInitializer from '@/providers/AppInitializer';
import QueryProvider from '@/providers/QueryProvider'; import QueryProvider from '@/providers/QueryProvider';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
const poppins = Poppins({ const poppins = Poppins({
variable: '--font-poppins', variable: '--font-poppins',
@@ -31,7 +41,11 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html
lang="en"
suppressHydrationWarning
className={cn('font-sans', "font-sans", inter.variable)}
>
<head> <head>
<link rel="stylesheet" href="/flags/flags.css" /> <link rel="stylesheet" href="/flags/flags.css" />
</head> </head>
@@ -44,8 +58,10 @@ export default function RootLayout({
> >
<QueryProvider> <QueryProvider>
<AppInitializer> <AppInitializer>
{children} <TooltipProvider delayDuration={0}>
<Toaster position="top-center" /> {children}
<Toaster position="top-center" />
</TooltipProvider>
</AppInitializer> </AppInitializer>
</QueryProvider> </QueryProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -1,57 +0,0 @@
import Image from 'next/image';
import { cn } from '@/lib/utils';
const sizeClasses = {
sm: 'size-8 rounded-md border-2 p-0.5',
md: 'size-12 rounded-md border-2 p-1.5',
lg: 'size-28 rounded-lg border-[5px] p-3',
} as const;
const variantClasses = {
default: 'border-white bg-primary shadow-md',
solid: 'border-white bg-primary',
glass: 'border-primary-foreground bg-primary/85 shadow-md',
sidebar: 'border-0 bg-primary',
} as const;
type LogoWrapperProps = {
alt?: string;
className?: string;
imageClassName?: string;
priority?: boolean;
size?: keyof typeof sizeClasses;
variant?: keyof typeof variantClasses;
};
export function LogoWrapper({
alt = 'RoadMonitor logo',
className,
imageClassName,
priority = false,
size = 'md',
variant = 'default',
}: LogoWrapperProps) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center',
sizeClasses[size],
variantClasses[variant],
className,
)}
>
<Image
src="/logo.png"
alt={alt}
width={112}
height={112}
className={cn(
'size-full object-contain brightness-0 invert',
imageClassName,
)}
priority={priority}
/>
</span>
);
}

View File

@@ -0,0 +1,66 @@
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { BoundingBoxOverlayItem } from '@/types';
interface BoundingBoxOverlayProps {
detections: BoundingBoxOverlayItem[];
videoWidth: number;
videoHeight: number;
}
export default function BoundingBoxOverlay({
detections,
videoWidth,
videoHeight,
}: BoundingBoxOverlayProps) {
if (videoWidth <= 0 || videoHeight <= 0 || detections.length === 0) {
return null;
}
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[2] size-full"
viewBox={`0 0 ${videoWidth} ${videoHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{detections.map((detection) => {
const { bounding_box: box } = detection;
const color = getDefectVisual(detection.class_name).boundingBoxColor;
const label = `${detection.display_name} ${(detection.confidence * 100).toFixed(0)}%`;
const labelY = Math.max(box.y1 - 34, 0);
const labelWidth = Math.max(150, label.length * 17);
return (
<g key={detection.id}>
<rect
x={box.x1}
y={box.y1}
width={box.x2 - box.x1}
height={box.y2 - box.y1}
fill="none"
stroke={color}
strokeWidth="4"
vectorEffect="non-scaling-stroke"
/>
<rect
x={box.x1}
y={labelY}
width={labelWidth}
height="34"
fill={color}
/>
<text
x={box.x1 + 8}
y={labelY + 23}
fill="white"
fontSize="20"
fontWeight="600"
>
{label}
</text>
</g>
);
})}
</svg>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { import {
Breadcrumb, Breadcrumb,
@@ -27,7 +28,9 @@ export function BreadcrumbBasic() {
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink> <BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
{segments.length > 0 && <BreadcrumbSeparator />} {segments.length > 0 && <BreadcrumbSeparator />}
@@ -44,8 +47,8 @@ export function BreadcrumbBasic() {
{isLast ? ( {isLast ? (
<BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage> <BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>
) : ( ) : (
<BreadcrumbLink href={href}> <BreadcrumbLink asChild>
{formatSegment(segment)} <Link href={href}>{formatSegment(segment)}</Link>
</BreadcrumbLink> </BreadcrumbLink>
)} )}
</BreadcrumbItem> </BreadcrumbItem>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { LogoWrapper } from '@/components/LogoWrapper'; import Image from 'next/image';
import { NavUser } from '@/components/nav-user'; import { NavUser } from '@/components/nav-user';
import { import {
filterMenuItems, filterMenuItems,
@@ -18,24 +18,36 @@ import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter, SidebarFooter,
SidebarGroup,
SidebarHeader, SidebarHeader,
SidebarRail,
SidebarMenu, SidebarMenu,
SidebarMenuItem,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarGroup, SidebarRail,
SidebarGroupLabel, SidebarTrigger,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useAppStore } from '@/store/app.store'; import { useAppStore } from '@/store/app.store';
function isRouteActive(pathname: string, path?: string) {
if (!path) return false;
return pathname === path || pathname.startsWith(`${path}/`);
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname(); const pathname = usePathname();
const { hasPermission } = usePermissions(); const { hasPermission } = usePermissions();
const { isMobile } = useSidebar();
const authUser = useAppStore((state) => state.user); const authUser = useAppStore((state) => state.user);
const navItems = filterMenuItems(menuItems, hasPermission); const navItems = filterMenuItems(menuItems, hasPermission);
const user = { const user = {
@@ -43,22 +55,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
[authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') ||
'Admin', 'Admin',
email: authUser?.email || '', email: authUser?.email || '',
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
}; };
return ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>
<SidebarHeader> <SidebarHeader className="border-b border-sidebar-border px-3 py-3 group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:border-b-0 group-data-[collapsible=icon]:px-2">
<div className="flex items-center gap-2 py-2"> <div className="flex h-8 w-full items-center gap-3 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center">
<LogoWrapper size="sm" variant="sidebar" /> <SidebarBrandControl isMobile={isMobile} />
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden"> <div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">RoadMonitor</span> <span className="truncate font-semibold">RoadMonitor</span>
<span className="truncate text-xs">Road Intelligence</span> <span className="truncate text-xs">Road Intelligence</span>
</div> </div>
<SidebarTrigger
className="ml-auto shrink-0 bg-sidebar/80 text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden"
aria-label={isMobile ? 'Close menu' : 'Hide menu'}
/>
</div> </div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent className="px-2 py-3">
<SidebarGroup> <SidebarGroup className="p-0">
<SidebarMenu> <SidebarMenu>
{navItems.map((item) => ( {navItems.map((item) => (
<SidebarNavItem <SidebarNavItem
@@ -70,7 +85,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter className="border-t border-sidebar-border px-3 py-3">
<NavUser user={user} /> <NavUser user={user} />
</SidebarFooter> </SidebarFooter>
<SidebarRail /> <SidebarRail />
@@ -78,6 +93,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
); );
} }
function SidebarBrandControl({ isMobile }: { isMobile: boolean }) {
return (
<div className="group/sidebar-brand relative flex size-8 shrink-0 items-center justify-center">
<Image
src="/color_logo.svg"
alt="RoadMonitor logo"
width={81}
height={58}
className="h-auto w-10 transition-opacity duration-150 group-data-[collapsible=icon]:group-hover/sidebar-brand:opacity-0 group-data-[collapsible=icon]:group-focus-within/sidebar-brand:opacity-0"
priority
/>
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger
className="pointer-events-none absolute inset-0 bg-sidebar text-sidebar-foreground/80 opacity-0 shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:pointer-events-auto group-data-[collapsible=icon]:group-hover/sidebar-brand:opacity-100 group-data-[collapsible=icon]:group-focus-within/sidebar-brand:opacity-100"
aria-label={isMobile ? 'Close menu' : 'Open menu'}
/>
</TooltipTrigger>
<TooltipContent side="right">Open menu</TooltipContent>
</Tooltip>
</div>
);
}
function SidebarNavItem({ function SidebarNavItem({
item, item,
pathname, pathname,
@@ -85,16 +124,23 @@ function SidebarNavItem({
item: MenuItem; item: MenuItem;
pathname: string; pathname: string;
}) { }) {
const isActive = item.path ? pathname === item.path : false; const isActive = isRouteActive(pathname, item.path);
const isChildActive = const isChildActive =
item.children?.some((child) => child.path === pathname) ?? false; item.children?.some((child) => isRouteActive(pathname, child.path)) ??
false;
const hasChildren = Boolean(item.children?.length); const hasChildren = Boolean(item.children?.length);
const [open, setOpen] = React.useState(isChildActive);
React.useEffect(() => {
if (isChildActive) setOpen(true);
}, [isChildActive]);
if (hasChildren) { if (hasChildren) {
return ( return (
<Collapsible <Collapsible
asChild asChild
defaultOpen={isChildActive} open={open}
onOpenChange={setOpen}
className="group/collapsible" className="group/collapsible"
> >
<SidebarMenuItem> <SidebarMenuItem>
@@ -112,7 +158,7 @@ function SidebarNavItem({
{child.path ? ( {child.path ? (
<SidebarMenuSubButton <SidebarMenuSubButton
asChild asChild
isActive={pathname === child.path} isActive={isRouteActive(pathname, child.path)}
> >
<Link href={child.path}> <Link href={child.path}>
<child.icon /> <child.icon />
@@ -147,4 +193,3 @@ function SidebarNavItem({
</SidebarMenuItem> </SidebarMenuItem>
); );
} }

View File

@@ -14,11 +14,9 @@ import {
ComboboxTrigger, ComboboxTrigger,
ComboboxValue, ComboboxValue,
} from '@/components/ui/combobox'; } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import type { import type { AsyncSelectOption, AsyncSelectProps } from './AsyncSelect.types';
AsyncSelectOption,
AsyncSelectProps,
} from './AsyncSelect.types';
import { VirtualSelectList } from './VirtualSelectList'; import { VirtualSelectList } from './VirtualSelectList';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>; type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
@@ -30,6 +28,8 @@ export function AsyncSelect({
searchPlaceholder = 'Search...', searchPlaceholder = 'Search...',
emptyMessage = 'No items found.', emptyMessage = 'No items found.',
disabled = false, disabled = false,
triggerClassName,
renderSelectedOption,
portalContainer, portalContainer,
}: AsyncSelectProps) { }: AsyncSelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -125,7 +125,10 @@ export function AsyncSelect({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full justify-between font-normal" className={cn(
'w-full justify-between font-normal',
triggerClassName,
)}
disabled={isDisabled} disabled={isDisabled}
> >
{isLoading && !selectedOption ? ( {isLoading && !selectedOption ? (
@@ -134,14 +137,20 @@ export function AsyncSelect({
Loading... Loading...
</span> </span>
) : ( ) : (
<ComboboxValue placeholder={triggerPlaceholder} /> <>
{selectedOption && renderSelectedOption ? (
renderSelectedOption(selectedOption)
) : (
<ComboboxValue placeholder={triggerPlaceholder} />
)}
</>
)} )}
</Button> </Button>
} }
/> />
<ComboboxContent <ComboboxContent
container={portalContainer} container={portalContainer}
className="min-w-[var(--anchor-width)]" className="min-w-(--anchor-width)"
> >
<div className="border-b p-2"> <div className="border-b p-2">
<ComboboxInput <ComboboxInput

View File

@@ -1,8 +1,9 @@
import type { RefObject } from 'react'; import type { ReactNode, RefObject } from 'react';
export type AsyncSelectOption = { export type AsyncSelectOption = {
value: string; value: string;
label: string; label: string;
data?: unknown;
}; };
export type PaginatedResult<T> = { export type PaginatedResult<T> = {
@@ -47,5 +48,7 @@ export interface AsyncSelectProps {
searchPlaceholder?: string; searchPlaceholder?: string;
emptyMessage?: string; emptyMessage?: string;
disabled?: boolean; disabled?: boolean;
triggerClassName?: string;
renderSelectedOption?: (option: AsyncSelectOption) => ReactNode;
portalContainer?: RefObject<HTMLDivElement | null>; portalContainer?: RefObject<HTMLDivElement | null>;
} }

View File

@@ -1,5 +1,5 @@
import { LogoWrapper } from '@/components/LogoWrapper';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import Image from 'next/image';
const BOKEH_ORBS = [ const BOKEH_ORBS = [
'top-[20%] left-[10%] size-[min(18vw,140px)] opacity-[0.14]', 'top-[20%] left-[10%] size-[min(18vw,140px)] opacity-[0.14]',
@@ -82,24 +82,21 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
/> />
))} ))}
<div className="absolute inset-x-10 top-1/2 -translate-y-1/2 text-center text-primary-foreground"> <Image
<div className="mb-9 inline-flex items-center gap-5 text-left"> src="/logo.svg"
<LogoWrapper alt=""
alt="" width={81}
className="size-28 rounded-2xl border-[5px] p-4" height={58}
priority className="absolute left-10 top-12 h-auto w-16 object-contain"
variant="glass" priority
/> />
<span className="min-w-0">
<span className="block text-5xl font-semibold leading-none tracking-normal"> <div className="absolute bottom-10 left-10 right-10 text-left text-primary-foreground">
Road <p className="text-3xl leading-none tracking-tight">
</span> <span className="font-semibold">Road</span>
<span className="block text-5xl font-semibold leading-none tracking-normal"> <span className="font-normal">Monitor</span>
Monitor </p>
</span> <p className="mt-1 max-w-sm text-xs leading-4 text-primary-foreground/90">
</span>
</div>
<p className="mx-auto max-w-md text-base leading-7 text-primary-foreground/82">
Map, monitor, and repair road issues with data-driven surface Map, monitor, and repair road issues with data-driven surface
intelligence. intelligence.
</p> </p>
@@ -107,4 +104,3 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
</div> </div>
); );
} }

View File

@@ -1,7 +1,12 @@
'use client'; 'use client';
import { Table } from '@tanstack/react-table'; import { Table } from '@tanstack/react-table';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -25,26 +30,33 @@ export function TableFooter<TData>({
pageSize?: number; pageSize?: number;
onPageSizeChange?: (pageSize: number) => void; onPageSizeChange?: (pageSize: number) => void;
}) { }) {
const rows = table.getRowModel().rows.length;
const currentPageSize = pageSize ?? table.getState().pagination.pageSize; const currentPageSize = pageSize ?? table.getState().pagination.pageSize;
const selectedRowCount = Object.keys(table.getState().rowSelection).length;
const pageIndex = table.getState().pagination.pageIndex;
const rawPageCount = table.getPageCount();
const pageCount = Math.max(
1,
Number.isFinite(rawPageCount) && rawPageCount > 0 ? rawPageCount : 1,
);
return ( return (
<div className="flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 px-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3"> <div>
<span> {selectedRowCount} of {totalItems} row(s) selected.
Showing {rows} of {totalItems} </div>
</span>
<div className="flex flex-wrap items-center gap-4 sm:justify-end">
{onPageSizeChange ? ( {onPageSizeChange ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-foreground">
<span>Rows per page</span> <span className="font-medium">Rows per page</span>
<Select <Select
value={String(currentPageSize)} value={String(currentPageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))} onValueChange={(value) => table.setPageSize(Number(value))}
> >
<SelectTrigger className="h-8 w-18 bg-background"> <SelectTrigger className="h-8 w-20 border-border bg-background shadow-none">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent align="end">
{PAGE_SIZE_OPTIONS.map((size) => ( {PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}> <SelectItem key={size} value={String(size)}>
{size} {size}
@@ -54,26 +66,57 @@ export function TableFooter<TData>({
</Select> </Select>
</div> </div>
) : null} ) : null}
</div>
<div className="flex gap-2"> <div className="min-w-24 text-foreground font-medium">
<Button Page {pageIndex + 1} of {pageCount}
variant="outline" </div>
size="sm"
onClick={() => table.previousPage()} <div className="flex items-center gap-2">
disabled={!table.getCanPreviousPage()} <Button
> type="button"
<ChevronLeft className="size-4" /> variant="outline"
Previous size="icon-sm"
</Button> className="size-8 shadow-none"
<Button onClick={() => table.setPageIndex(0)}
variant="outline" disabled={!table.getCanPreviousPage()}
size="sm" aria-label="Go to first page"
onClick={() => table.nextPage()} >
disabled={!table.getCanNextPage()} <ChevronsLeft className="size-4" />
> </Button>
Next <Button
<ChevronRight className="size-4" /> type="button"
</Button> variant="outline"
size="icon-sm"
className="size-8 shadow-none"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="Go to previous page"
>
<ChevronLeft className="size-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
className="size-8 shadow-none"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="Go to next page"
>
<ChevronRight className="size-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
className="size-8 shadow-none"
onClick={() => table.setPageIndex(pageCount - 1)}
disabled={!table.getCanNextPage()}
aria-label="Go to last page"
>
<ChevronsRight className="size-4" />
</Button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -21,26 +21,35 @@ const TopHeader = ({
tableTools, tableTools,
}: TopHeaderProps) => { }: TopHeaderProps) => {
return ( return (
<div className="space-y-4"> <div className="space-y-3">
{(title || onAddNew) && ( {(title || onAddNew) && (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div> <div className="min-w-0">
{title ? <h2>{title}</h2> : null} {title ? <h2 className="text-lg font-semibold">{title}</h2> : null}
<p className="text-muted-foreground">Total {itemCount ?? 0}</p> <p className="text-sm text-muted-foreground">
Total {itemCount ?? 0}
</p>
</div> </div>
{onAddNew ? ( {onAddNew ? (
<Button onClick={onAddNew}> <Button onClick={onAddNew} size="sm" className="w-fit">
<Plus className="mr-2 size-4" /> <Plus className="size-4" />
{addButtonText} {addButtonText}
</Button> </Button>
) : null} ) : null}
</div> </div>
)} )}
{(toolbar || tableTools) && ( {(toolbar || tableTools) && (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1">{toolbar}</div> {toolbar ? (
<div className="min-w-0 flex-1 **:data-[slot=input-group]:border-border [&_[data-slot=input-group]]:bg-background [&_[data-slot=input-group]]:shadow-none [&_[data-slot=select-trigger]]:border-border [&_[data-slot=select-trigger]]:bg-background [&_[data-slot=select-trigger]]:shadow-none">
{toolbar}
</div>
) : null}
{tableTools ? ( {tableTools ? (
<div className="flex shrink-0 justify-end">{tableTools}</div> <div className="flex shrink-0 justify-end **:data-[slot=button]:shadow-none">
{tableTools}
</div>
) : null} ) : null}
</div> </div>
)} )}

View File

@@ -16,7 +16,7 @@ import { getPinnedColumnStyles } from './columnStyles';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => { const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
return ( return (
<ShadTableHeader className="sticky top-0 z-20 bg-card"> <ShadTableHeader className="sticky top-0 z-20 bg-muted/70 backdrop-blur supports-backdrop-filter:bg-muted/60">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
@@ -39,9 +39,9 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
canSort ? header.column.getToggleSortingHandler() : undefined canSort ? header.column.getToggleSortingHandler() : undefined
} }
className={cn( className={cn(
'h-10 max-w-0 overflow-hidden border-b border-border bg-card px-2 text-foreground transition-colors', 'h-11 max-w-0 overflow-hidden border-b border-border bg-muted/70 px-4 text-sm font-semibold text-foreground transition-colors',
canSort && 'cursor-pointer select-none hover:bg-muted/60', canSort && 'cursor-pointer select-none hover:bg-muted',
sortDirection && 'bg-muted/70', sortDirection && 'bg-muted',
)} )}
> >
{header.isPlaceholder ? null : ( {header.isPlaceholder ? null : (
@@ -70,7 +70,7 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
onMouseDown={header.getResizeHandler()} onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()} onTouchStart={header.getResizeHandler()}
className={cn( className={cn(
'absolute -right-2 top-0 h-full w-1 cursor-col-resize touch-none select-none rounded bg-border opacity-0 transition-opacity hover:opacity-100', 'absolute -right-2 top-0 h-full w-1 cursor-col-resize touch-none select-none rounded-lg bg-border opacity-0 transition-opacity hover:opacity-100',
header.column.getIsResizing() && 'opacity-100', header.column.getIsResizing() && 'opacity-100',
)} )}
/> />

View File

@@ -23,14 +23,20 @@ export function TableSearchInput({
className, className,
}: TableSearchInputProps) { }: TableSearchInputProps) {
return ( return (
<InputGroup className={cn('md:max-w-xs', className)}> <InputGroup
className={cn(
'h-9 border-border bg-background shadow-none md:max-w-xs',
className,
)}
>
<InputGroupAddon> <InputGroupAddon>
<Search /> <Search className="size-4" />
</InputGroupAddon> </InputGroupAddon>
<InputGroupInput <InputGroupInput
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
placeholder={placeholder} placeholder={placeholder}
className="placeholder:text-muted-foreground/70"
/> />
</InputGroup> </InputGroup>
); );

Some files were not shown because too many files have changed in this diff Show More