Compare commits
33 Commits
14220445ad
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 224361f144 | |||
| 77b6755d05 | |||
| 2f25fd49be | |||
| 3891500cf2 | |||
| 062e89fcba | |||
| 725c256791 | |||
| 5d3dac0b21 | |||
| e077467bce | |||
| e94da54aa9 | |||
| 76c87811d8 | |||
| 830883c6b7 | |||
| 68829dc72b | |||
| 9d7f956c3b | |||
| 0026824f84 | |||
| a18eb64dac | |||
| 6c71d8f8a7 | |||
| 0e2172366b | |||
| 56180f8d18 | |||
| 5269b29aac | |||
| 9fe7109faf | |||
| 9720546455 | |||
| 8727963c54 | |||
| 0e8108b875 | |||
| 87dde1c8d1 | |||
| a376558b2f | |||
| 4fe93460d9 | |||
| cbfb6e040b | |||
| 7a82e17bbb | |||
| 23186ffd29 | |||
| fb9a654aea | |||
| d88978a830 | |||
| ba9731de86 | |||
| c0f3a718ef |
@@ -1,2 +1,2 @@
|
|||||||
# Backend API base URL (no trailing slash)
|
# 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,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": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
3381
package-lock.json
generated
3381
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"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",
|
||||||
@@ -42,7 +44,9 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"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",
|
"vaul": "^1.1.2",
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
},
|
},
|
||||||
"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
5
public/color_logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
|
||||||
|
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
|
||||||
|
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
5
public/logo.svg
Normal file
5
public/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
|
||||||
|
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
|
||||||
|
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -33,7 +33,7 @@ function ResetPasswordContent() {
|
|||||||
</header>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||||
|
import { ModeToggle } from '@/components/mode-toogle';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
|
||||||
|
|
||||||
|
export function ModuleShellHeader() {
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-backdrop-filter:bg-background/80">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
{isMobile ? (
|
||||||
|
<>
|
||||||
|
<SidebarTrigger className="shrink-0 bg-background/80 text-muted-foreground shadow-xs backdrop-blur-sm" />
|
||||||
|
<Separator orientation="vertical" className="h-4 shrink-0" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<BreadcrumbBasic />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { FormField, PhoneInput } from '@/components/form';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { 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"
|
||||||
|
|||||||
@@ -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="scroll-stable 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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +14,9 @@ function StatSkeleton() {
|
|||||||
|
|
||||||
function DetectionLogSkeleton() {
|
function DetectionLogSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-card p-3">
|
<div className="rounded-lg border bg-card p-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Skeleton className="size-20 shrink-0 rounded-md" />
|
<Skeleton className="size-20 shrink-0 rounded-lg" />
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -41,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" />
|
||||||
@@ -50,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" />
|
||||||
@@ -76,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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } from 'lucide-react';
|
||||||
|
|
||||||
|
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { DetectionResultItem } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
import { RepairProofGallery } from '../../../components/repair/RepairProofGallery';
|
||||||
|
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
|
||||||
|
|
||||||
|
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
|
||||||
|
not_submitted: 'Awaiting Repair',
|
||||||
|
pending: 'Under Review',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
const REVIEW_STATUS_CLASS: Record<IssueReviewStatus, string> = {
|
||||||
|
not_submitted: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||||
|
pending: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||||
|
approved: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||||
|
rejected: 'bg-destructive/10 text-destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
function MetadataItem({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-1 px-4 first:pl-0 last:pr-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<div className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueEvidencePanelProps {
|
||||||
|
detection: DetectionResultItem;
|
||||||
|
issueNumber: number;
|
||||||
|
videoWidth: number;
|
||||||
|
videoHeight: number;
|
||||||
|
reviewStatus: IssueReviewStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueEvidencePanel({
|
||||||
|
detection,
|
||||||
|
issueNumber,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
reviewStatus,
|
||||||
|
}: IssueEvidencePanelProps) {
|
||||||
|
const repairProof = detection.repair_proof;
|
||||||
|
const displayName = detection.detection.display_name;
|
||||||
|
const confidence = Math.round(detection.detection.confidence * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{displayName} #{issueNumber}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(REVIEW_STATUS_CLASS[reviewStatus])}
|
||||||
|
>
|
||||||
|
{REVIEW_STATUS_LABEL[reviewStatus]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 divide-x sm:grid-cols-4">
|
||||||
|
<MetadataItem
|
||||||
|
label="Video Timestamp"
|
||||||
|
value={formatVideoTimestamp(detection.frame.timestamp_seconds)}
|
||||||
|
/>
|
||||||
|
<MetadataItem label="Frame" value={detection.frame.number} />
|
||||||
|
<MetadataItem label="Confidence" value={`${confidence}%`} />
|
||||||
|
<MetadataItem label="Issue ID" value={detection.detection.id} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
||||||
|
<section className="min-w-0 space-y-2">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<ImageIcon className="size-4 text-primary" />
|
||||||
|
Detection Image (Before)
|
||||||
|
</p>
|
||||||
|
<AnnotatedDetectionImage
|
||||||
|
detection={detection}
|
||||||
|
videoWidth={videoWidth}
|
||||||
|
videoHeight={videoHeight}
|
||||||
|
aspectRatio="16 / 9"
|
||||||
|
enableLightbox
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-w-0 space-y-2">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<MapPin className="size-4 text-primary" />
|
||||||
|
Detection Location
|
||||||
|
</p>
|
||||||
|
<DetectionLocationMap
|
||||||
|
latitude={detection.location.latitude}
|
||||||
|
longitude={detection.location.longitude}
|
||||||
|
label={`${displayName} #${issueNumber} - Frame ${detection.frame.number}`}
|
||||||
|
title=""
|
||||||
|
variant="plain"
|
||||||
|
aspectRatio="16 / 9"
|
||||||
|
showCoordinates={false}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<CalendarCheck2 className="size-4 text-primary" />
|
||||||
|
Repair Proof (After Repair)
|
||||||
|
</p>
|
||||||
|
{repairProof?.submitted ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<Smartphone />
|
||||||
|
Uploaded from App
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{!repairProof?.submitted ? (
|
||||||
|
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Repair proof is not submitted yet.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
This issue cannot be reviewed until repair evidence is
|
||||||
|
available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{repairProof.submitted_at ? (
|
||||||
|
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
||||||
|
Uploaded from the field app on{' '}
|
||||||
|
{formatDate(repairProof.submitted_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RepairProofGallery
|
||||||
|
imageUrls={repairProof.proof_image_urls}
|
||||||
|
maxVisibleImages={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 rounded-lg border bg-card p-4 shadow-xs">
|
||||||
|
<p className="text-xs font-semibold text-foreground">
|
||||||
|
Repair Notes
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||||
|
{repairProof.notes?.trim() ||
|
||||||
|
'No repair notes were provided.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export function IssueReviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-56" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-9 w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)_330px]">
|
||||||
|
{[280, 600, 330].map((width) => (
|
||||||
|
<Card key={width}>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
|
<Skeleton key={index} className="h-20 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { DetectionProofStatus, DetectionResultItem } from '@/types';
|
||||||
|
|
||||||
|
export type ProofStatusFilter =
|
||||||
|
| 'all'
|
||||||
|
| Extract<DetectionProofStatus, 'pending' | 'approved' | 'rejected'>;
|
||||||
|
|
||||||
|
export type IssueReviewStatus =
|
||||||
|
| 'not_submitted'
|
||||||
|
| 'pending'
|
||||||
|
| 'approved'
|
||||||
|
| 'rejected';
|
||||||
|
|
||||||
|
export function getIssueId(detection: DetectionResultItem) {
|
||||||
|
return String(detection.detection.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIssueReviewStatus(
|
||||||
|
detection: DetectionResultItem,
|
||||||
|
): IssueReviewStatus {
|
||||||
|
const repairProof = detection.repair_proof;
|
||||||
|
if (!repairProof?.submitted) return 'not_submitted';
|
||||||
|
|
||||||
|
return repairProof.review_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVideoTimestamp(totalSeconds: number) {
|
||||||
|
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainder = seconds % 60;
|
||||||
|
|
||||||
|
return `${minutes}:${remainder.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,85 +1,71 @@
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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() {
|
export function TicketClassContentSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5" aria-label="Loading defect class details">
|
<div className="space-y-4" aria-label="Loading defect class details">
|
||||||
<Card>
|
<div className="flex items-center gap-2">
|
||||||
<CardHeader className="sm:grid-cols-[1fr_auto]">
|
<Skeleton className="size-5 rounded-lg" />
|
||||||
<Skeleton className="h-5 w-36" />
|
<Skeleton className="h-5 w-56 max-w-full" />
|
||||||
<Skeleton className="h-3 w-32 sm:col-start-2 sm:row-start-1" />
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
{Array.from({ length: 2 }).map((_, index) => (
|
|
||||||
<div key={index} className="space-y-1">
|
|
||||||
<Skeleton className="h-3 w-20" />
|
|
||||||
<Skeleton className="h-4 w-36" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
<div className="rounded-lg border bg-muted/15 px-4 py-3">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="mt-2 h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Timeline>
|
||||||
<CardHeader>
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
<Skeleton className="h-5 w-40" />
|
<TimelineEntrySkeleton
|
||||||
</CardHeader>
|
key={index}
|
||||||
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
index={index}
|
||||||
<Skeleton className="aspect-video w-full rounded-md" />
|
isLast={index === 2}
|
||||||
<Skeleton className="aspect-video w-full rounded-md" />
|
/>
|
||||||
</CardContent>
|
))}
|
||||||
</Card>
|
</Timeline>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
|
||||||
<div key={index} className="space-y-2 rounded-lg border p-2">
|
|
||||||
<Skeleton className="aspect-4/3 w-full rounded-lg" />
|
|
||||||
<Skeleton className="h-3 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="size-5 rounded" />
|
|
||||||
<Skeleton className="h-5 w-56" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
|
||||||
<div key={index} className="relative flex gap-4 pb-8 last:pb-0">
|
|
||||||
{index < 2 ? (
|
|
||||||
<span className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" />
|
|
||||||
) : null}
|
|
||||||
<Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" />
|
|
||||||
<div className="min-w-0 flex-1 space-y-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<Skeleton className="h-4 w-44" />
|
|
||||||
<Skeleton className="h-3 w-28" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-3 w-56" />
|
|
||||||
<Skeleton className="h-12 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -89,7 +75,7 @@ export function TicketClassActionsSkeleton() {
|
|||||||
<Card aria-label="Loading defect class actions">
|
<Card aria-label="Loading defect class actions">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="size-4 rounded" />
|
<Skeleton className="size-4 rounded-lg" />
|
||||||
<Skeleton className="h-5 w-32" />
|
<Skeleton className="h-5 w-32" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { AlertTriangle, 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();
|
||||||
@@ -56,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>
|
||||||
);
|
);
|
||||||
@@ -87,49 +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">
|
||||||
<AlertTriangle className="size-5 shrink-0 text-primary" />
|
<div className="flex items-center gap-2">
|
||||||
<span>Issues</span>
|
<ListChecks className="size-5 shrink-0 text-primary" />
|
||||||
<Badge
|
<h2 className="text-base font-semibold">Detected Issues</h2>
|
||||||
variant="secondary"
|
<Badge
|
||||||
className="min-w-5 min-h-5 justify-center rounded-full"
|
variant="secondary"
|
||||||
>
|
className="min-w-5 min-h-5 justify-center rounded-full"
|
||||||
{defectClasses.length}
|
>
|
||||||
</Badge>
|
{defectClasses.length}
|
||||||
</CardTitle>
|
</Badge>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
|
|
||||||
{defectClasses.map((item) => {
|
|
||||||
const statusConfig =
|
|
||||||
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
|
|
||||||
|
|
||||||
return (
|
<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>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function TicketDetailHeader({
|
|||||||
ticket: TicketOverviewDetail;
|
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">
|
||||||
@@ -23,8 +23,7 @@ export function TicketDetailHeader({
|
|||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push('/ticket')}
|
onClick={() => router.push('/ticket')}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) {
|
|||||||
if (!noteText) return null;
|
if (!noteText) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-lg border border-amber-300/80 bg-amber-50/90 px-3 py-2.5 dark:border-amber-500/30 dark:bg-amber-500/10">
|
<div className="mt-3 rounded-lg border border-amber-300/80 bg-amber-50/90 px-3 py-2.5 dark:border-amber-500/30 dark:bg-amber-500/10">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
|
||||||
<MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
|
<MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
|
||||||
<span>
|
<span>
|
||||||
@@ -48,10 +48,10 @@ function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
|||||||
const noteText = item.note?.text?.trim();
|
const noteText = item.note?.text?.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
|
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
|
||||||
<Badge
|
<Badge
|
||||||
|
|
||||||
className="rounded-xs px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
|
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
|
||||||
>
|
>
|
||||||
{getSourceLabel(item.source)}
|
{getSourceLabel(item.source)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -70,7 +70,7 @@ function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
|||||||
const isAssigned = item.event_type === 'assigned';
|
const isAssigned = item.event_type === 'assigned';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3>
|
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||||
<PersonInfo person={item.actor} />
|
<PersonInfo person={item.actor} />
|
||||||
{isAssigned && item.target_user ? (
|
{isAssigned && item.target_user ? (
|
||||||
@@ -124,7 +124,7 @@ export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
|||||||
))}
|
))}
|
||||||
</Timeline>
|
</Timeline>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
No timeline entries yet
|
No timeline entries yet
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import type { MediaPlayerInstance } from '@vidstack/react';
|
||||||
|
|
||||||
|
import { useProtectedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
|
||||||
|
import AnnotatedVideoPlayer from '@/components/video/annotatedVideoPlayer';
|
||||||
|
import { useVideoAnnotationPlayback } from '@/components/video/useVideoAnnotationPlayback';
|
||||||
|
import type { TicketOverviewDetail } from '@/types';
|
||||||
|
|
||||||
|
type TicketInlineAnalysisVideoProps = {
|
||||||
|
ticket: TicketOverviewDetail;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TicketInlineAnalysisVideo({
|
||||||
|
ticket,
|
||||||
|
}: TicketInlineAnalysisVideoProps) {
|
||||||
|
const videoRef = useRef<MediaPlayerInstance | null>(null);
|
||||||
|
const videoId = ticket.video_id ?? ticket.video?.id ?? undefined;
|
||||||
|
const sourceUrl = ticket.video?.url ?? undefined;
|
||||||
|
const fps = ticket.video_metadata?.fps ?? 0;
|
||||||
|
const videoWidth = ticket.video_metadata?.resolution.width ?? 0;
|
||||||
|
const videoHeight = ticket.video_metadata?.resolution.height ?? 0;
|
||||||
|
const {
|
||||||
|
visibleDetections,
|
||||||
|
handleSeeked,
|
||||||
|
handleTimeUpdate,
|
||||||
|
handleVideoFrame,
|
||||||
|
} = useVideoAnnotationPlayback({
|
||||||
|
videoId,
|
||||||
|
logs: [],
|
||||||
|
fps,
|
||||||
|
videoRef,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
videoUrl,
|
||||||
|
isLoading: isVideoLoading,
|
||||||
|
isError: isVideoError,
|
||||||
|
refetch: refetchVideo,
|
||||||
|
} = useProtectedVideoQuery(sourceUrl);
|
||||||
|
|
||||||
|
if (!videoId || !sourceUrl) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-sm text-muted-foreground">
|
||||||
|
Analysis video is unavailable for this ticket.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<AnnotatedVideoPlayer
|
||||||
|
videoRef={videoRef}
|
||||||
|
logs={[]}
|
||||||
|
visibleDetections={visibleDetections}
|
||||||
|
videoWidth={videoWidth}
|
||||||
|
videoHeight={videoHeight}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
isLoading={isVideoLoading}
|
||||||
|
isError={isVideoError}
|
||||||
|
onRetry={() => void refetchVideo()}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onVideoFrame={handleVideoFrame}
|
||||||
|
onSeeked={handleSeeked}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,16 +2,15 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowRight,
|
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Clock3,
|
Clock3,
|
||||||
Gauge,
|
Gauge,
|
||||||
LayoutDashboard,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Monitor,
|
Monitor,
|
||||||
UserRound,
|
UserRound,
|
||||||
|
Play
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -21,12 +20,21 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { TicketOverviewDetail } from '@/types';
|
import type { TicketOverviewDetail } from '@/types';
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
import { TicketInlineAnalysisVideo } from './TicketInlineAnalysisVideo';
|
||||||
|
|
||||||
type IconComponent = React.ComponentType<{ className?: string }>;
|
type IconComponent = React.ComponentType<{ className?: string }>;
|
||||||
|
|
||||||
@@ -62,18 +70,16 @@ function SummaryCard({
|
|||||||
value,
|
value,
|
||||||
colorClassName,
|
colorClassName,
|
||||||
cardClassName,
|
cardClassName,
|
||||||
icon: Icon,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
colorClassName: string;
|
colorClassName: string;
|
||||||
cardClassName: string;
|
cardClassName: string;
|
||||||
icon?: IconComponent;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60',
|
'rounded-lg bg-muted/40 p-4 hover:scale-105 transition-transform ease-in-out',
|
||||||
cardClassName,
|
cardClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -103,81 +109,68 @@ function VideoMetric({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewStat({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
valueClassName,
|
|
||||||
icon: Icon,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: React.ReactNode;
|
|
||||||
valueClassName?: string;
|
|
||||||
icon: IconComponent;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-3 rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<MetaLabel>{label}</MetaLabel>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'mt-2 truncate text-lg font-bold tracking-tight text-foreground',
|
|
||||||
valueClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Icon className={cn('size-5 shrink-0 opacity-80', valueClassName)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketOverviewCard({
|
export function TicketOverviewCard({
|
||||||
ticket,
|
ticket,
|
||||||
defectClass,
|
|
||||||
}: {
|
}: {
|
||||||
ticket: TicketOverviewDetail;
|
ticket: TicketOverviewDetail;
|
||||||
defectClass?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const [isAnalysisOpen, setIsAnalysisOpen] = useState(false);
|
||||||
const currentIssue =
|
const videoMetadata = ticket.video_metadata;
|
||||||
ticket.ai_result.detections_by_class.find(
|
const duration =
|
||||||
(item) => item.class_name === defectClass,
|
typeof videoMetadata?.duration_seconds === 'number'
|
||||||
) ?? ticket.ai_result.detections_by_class[0];
|
? `${Math.floor(videoMetadata.duration_seconds / 60)}:${Math.floor(
|
||||||
const currentVisual = getDefectVisual(currentIssue?.class_name ?? '');
|
videoMetadata.duration_seconds % 60,
|
||||||
const minutes = Math.floor(ticket.video_metadata.duration_seconds / 60);
|
)
|
||||||
const seconds = Math.floor(ticket.video_metadata.duration_seconds % 60);
|
.toString()
|
||||||
const duration = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
.padStart(2, '0')}`
|
||||||
|
: null;
|
||||||
const chainageName =
|
const chainageName =
|
||||||
ticket.location?.chainage?.name || ticket.chainage_name || null;
|
ticket.location?.chainage?.name || ticket.chainage_name || null;
|
||||||
const packageName = ticket.location?.package?.name || null;
|
const packageName = ticket.location?.package?.name || null;
|
||||||
const locationLabel =
|
const locationLabel =
|
||||||
[chainageName, packageName].filter(Boolean).join(', ') || '—';
|
[chainageName, packageName].filter(Boolean).join(', ') || null;
|
||||||
|
const uploadedOn =
|
||||||
|
ticket.timestamps?.created_at || ticket.ai_result.completed_at || null;
|
||||||
|
const uploaderName = ticket.uploader?.name || null;
|
||||||
|
const analysisVideoId = ticket.video_id || ticket.video?.id || null;
|
||||||
|
const videoName = ticket.video?.name || null;
|
||||||
|
const analysisTitle = videoName || ticket.ticket_name || 'Analysis Video';
|
||||||
|
const hasVideoMetrics = Boolean(
|
||||||
|
videoMetadata?.fps || duration || videoMetadata?.resolution?.label,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Card>
|
{uploadedOn || uploaderName || locationLabel ? (
|
||||||
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
<Card>
|
||||||
<div className="flex items-center gap-3 sm:pr-4">
|
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||||
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
{uploadedOn ? (
|
||||||
<OverviewField label="Uploaded On">
|
<div className="flex items-center gap-3 sm:pr-4">
|
||||||
<MetaValue>{formatDate(ticket.timestamps.created_at)}</MetaValue>
|
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
||||||
</OverviewField>
|
<OverviewField label="Uploaded On">
|
||||||
</div>
|
<MetaValue>{formatDate(uploadedOn)}</MetaValue>
|
||||||
<div className="flex items-center gap-3 sm:px-4">
|
</OverviewField>
|
||||||
<UserRound className="size-5 shrink-0 text-muted-foreground" />
|
</div>
|
||||||
<OverviewField label="Uploaded By">
|
) : null}
|
||||||
<MetaValue>{ticket.uploader.name}</MetaValue>
|
{uploaderName ? (
|
||||||
</OverviewField>
|
<div className="flex items-center gap-3 sm:px-4">
|
||||||
</div>
|
<UserRound className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-3 sm:pl-4">
|
<OverviewField label="Uploaded By">
|
||||||
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
<MetaValue>{uploaderName}</MetaValue>
|
||||||
<OverviewField label="Location">
|
</OverviewField>
|
||||||
<MetaValue>{locationLabel}</MetaValue>
|
</div>
|
||||||
</OverviewField>
|
) : null}
|
||||||
</div>
|
{locationLabel ? (
|
||||||
</CardContent>
|
<div className="flex items-center gap-3 sm:pl-4">
|
||||||
</Card>
|
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
<OverviewField label="Location">
|
||||||
|
<MetaValue>{locationLabel}</MetaValue>
|
||||||
|
</OverviewField>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -185,17 +178,15 @@ export function TicketOverviewCard({
|
|||||||
<Activity className="size-5 text-primary" />
|
<Activity className="size-5 text-primary" />
|
||||||
AI Detection Summary
|
AI Detection Summary
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{ticket.video_id ? (
|
{analysisVideoId ? (
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
size="sm"
|
onClick={() => setIsAnalysisOpen(true)}
|
||||||
className="text-primary hover:bg-primary/10 hover:text-primary"
|
|
||||||
onClick={() => router.push(`/results/${ticket.video_id}`)}
|
|
||||||
>
|
>
|
||||||
View Analysis
|
<Play className="mr-2 size-4" />
|
||||||
<ArrowRight className="size-3.5" />
|
View Video
|
||||||
</Button>
|
</Button>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
@@ -216,55 +207,44 @@ export function TicketOverviewCard({
|
|||||||
key={item.class_name}
|
key={item.class_name}
|
||||||
label={item.display_name}
|
label={item.display_name}
|
||||||
value={item.count}
|
value={item.count}
|
||||||
icon={visual.icon}
|
|
||||||
colorClassName={visual.colorClassName}
|
colorClassName={visual.colorClassName}
|
||||||
cardClassName={visual.cardClassName}
|
cardClassName={visual.cardClassName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
{hasVideoMetrics ? (
|
||||||
<VideoMetric
|
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
||||||
icon={Gauge}
|
{videoMetadata?.fps ? (
|
||||||
label={`${ticket.video_metadata.fps} FPS`}
|
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
|
||||||
/>
|
) : null}
|
||||||
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
{duration ? (
|
||||||
<VideoMetric
|
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
||||||
icon={Monitor}
|
) : null}
|
||||||
label={`${ticket.video_metadata.resolution.label} Resolution`}
|
{videoMetadata?.resolution?.label ? (
|
||||||
/>
|
<VideoMetric
|
||||||
</div>
|
icon={Monitor}
|
||||||
|
label={`${videoMetadata.resolution.label} Resolution`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
|
||||||
<CardHeader>
|
<DialogContent className="sm:max-w-5xl">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<DialogHeader>
|
||||||
<LayoutDashboard className="size-5 text-primary" />
|
<DialogTitle>{analysisTitle}</DialogTitle>
|
||||||
Ticket Overview
|
<DialogDescription>
|
||||||
</CardTitle>
|
Video analysis with annotation overlay
|
||||||
</CardHeader>
|
</DialogDescription>
|
||||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
</DialogHeader>
|
||||||
<OverviewStat
|
<DialogBody>
|
||||||
icon={currentVisual.icon}
|
<TicketInlineAnalysisVideo ticket={ticket} />
|
||||||
label="Current Issue"
|
</DialogBody>
|
||||||
value={currentIssue?.display_name ?? '-'}
|
</DialogContent>
|
||||||
valueClassName={currentVisual.colorClassName}
|
</Dialog>
|
||||||
/>
|
|
||||||
<OverviewStat
|
|
||||||
icon={Activity}
|
|
||||||
valueClassName="text-violet-600"
|
|
||||||
label="Current Issue Count"
|
|
||||||
value={currentIssue?.count ?? 0}
|
|
||||||
/>
|
|
||||||
<OverviewStat
|
|
||||||
icon={LayoutDashboard}
|
|
||||||
valueClassName="text-violet-600"
|
|
||||||
label="Total AI Detections"
|
|
||||||
value={ticket.ai_result.detection_count}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
function OverviewSkeletonField() {
|
function OverviewMetaSkeletonField({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className={`flex items-center gap-3 ${className ?? ''}`}>
|
||||||
<Skeleton className="h-3 w-24" />
|
<Skeleton className="size-5 shrink-0 rounded-lg" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -27,15 +45,45 @@ export function TicketOverviewHeaderSkeleton() {
|
|||||||
|
|
||||||
export function TicketOverviewCardSkeleton() {
|
export function TicketOverviewCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card aria-label="Loading ticket overview">
|
<div className="space-y-5" aria-label="Loading ticket overview">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<Skeleton className="h-4 w-20" />
|
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||||
</CardHeader>
|
<OverviewMetaSkeletonField className="sm:pr-4" />
|
||||||
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
|
<OverviewMetaSkeletonField className="sm:px-4" />
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
<OverviewMetaSkeletonField className="sm:pl-4" />
|
||||||
<OverviewSkeletonField key={index} />
|
</CardContent>
|
||||||
))}
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="size-5 rounded-lg" />
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</div>
|
||||||
|
<CardAction>
|
||||||
|
<Skeleton className="h-9 w-28" />
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<SummarySkeletonCard key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 sm:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Skeleton className="size-4 rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ImageIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
|
|
||||||
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
|
|
||||||
import { RepairReportPanel } from './repair-report/RepairReportPanel';
|
|
||||||
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
|
|
||||||
|
|
||||||
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const repair = ticket.repair;
|
|
||||||
|
|
||||||
if (!repair) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-5">
|
|
||||||
<RepairReportPanel ticket={ticket} />
|
|
||||||
|
|
||||||
<RepairVideoComparison ticket={ticket} />
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<ImageIcon className="size-5 text-primary" />
|
|
||||||
Repair Images
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<RepairProofImagesGrid
|
|
||||||
imageUrls={repair?.proof_image_urls ?? []}
|
|
||||||
submittedAt={repair?.submitted_at ?? null}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||||
|
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
|
||||||
|
|
||||||
interface TicketStatusActionsProps {
|
interface TicketStatusActionsProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
@@ -36,11 +37,14 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
permissions={PERMISSIONS.TICKET.WORK}
|
permissions={PERMISSIONS.TICKET.WORK}
|
||||||
fallback={<NoTicketAction />}
|
fallback={<NoTicketAction />}
|
||||||
>
|
>
|
||||||
<SubmitRepairAction ticket={ticket} />
|
<RequestExtensionAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReviewExtensionRequestAction ticket={ticket} />
|
<>
|
||||||
|
<AssignedContractorAction ticket={ticket} />
|
||||||
|
<ReviewExtensionRequestAction ticket={ticket} />
|
||||||
|
</>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,7 +55,7 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||||
fallback={<NoTicketAction />}
|
fallback={<NoTicketAction />}
|
||||||
>
|
>
|
||||||
<ReviewRepairAction ticket={ticket} />
|
<OpenRepairReviewAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,5 +71,5 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||||
return getTicketAction(ticket);
|
return <div className="space-y-3">{getTicketAction(ticket)}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
|
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { DatePicker } from '@/components/form/DatePicker';
|
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';
|
||||||
@@ -21,6 +21,15 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
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={Users} title="Assign to Contractor">
|
<TicketActionCard icon={Users} title="Assign to Contractor">
|
||||||
@@ -56,13 +65,20 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Due Date *</Label>
|
<Label htmlFor="assign-due-date">
|
||||||
<DatePicker
|
Due Date <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<DatePickerSimple
|
||||||
|
id="assign-due-date"
|
||||||
value={dueDate}
|
value={dueDate}
|
||||||
onChange={setDueDate}
|
onChange={setDueDate}
|
||||||
showTimeZone={false}
|
showLabel={false}
|
||||||
|
placeholder="Select due date"
|
||||||
disabled={assignMutation.isPending}
|
disabled={assignMutation.isPending}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
startMonth={today}
|
||||||
|
endMonth={maxDueMonth}
|
||||||
|
disabledDates={{ before: today }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
</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={<PersonInfo person={reviewer} size="lg" />}
|
value={<PersonInfo person={reviewer} size="lg" />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
||||||
import {
|
|
||||||
Camera,
|
|
||||||
ImageIcon,
|
|
||||||
Loader2,
|
|
||||||
Play,
|
|
||||||
Plus,
|
|
||||||
Send,
|
|
||||||
Video,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
|
||||||
|
|
||||||
const MAX_IMAGES = 30;
|
|
||||||
const MAX_NOTES = 500;
|
|
||||||
|
|
||||||
function RequiredMark() {
|
|
||||||
return <span className="text-destructive">*</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVideoDuration(seconds: number) {
|
|
||||||
const totalSeconds = Math.floor(seconds);
|
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
|
||||||
const remainingSeconds = totalSeconds % 60;
|
|
||||||
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaSectionHeaderProps {
|
|
||||||
icon: ReactNode;
|
|
||||||
title: string;
|
|
||||||
addLabel: string;
|
|
||||||
onAdd: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaSectionHeader({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
addLabel,
|
|
||||||
onAdd,
|
|
||||||
disabled = false,
|
|
||||||
}: MediaSectionHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-primary">{icon}</span>
|
|
||||||
<span className="text-sm font-semibold">{title}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onAdd}
|
|
||||||
className="h-8 shrink-0 border-primary/30 text-primary hover:bg-primary/5 hover:text-primary"
|
|
||||||
>
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
{addLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaHint({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmptyMediaPlaceholderProps {
|
|
||||||
icon: ReactNode;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyMediaPlaceholder({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
className,
|
|
||||||
disabled = false,
|
|
||||||
onClick,
|
|
||||||
}: EmptyMediaPlaceholderProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full min-w-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/80 bg-muted/15 px-4 py-6 text-center transition-colors',
|
|
||||||
'hover:border-primary/40 hover:bg-primary/5',
|
|
||||||
disabled && 'cursor-not-allowed opacity-60',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
|
||||||
<span className="max-w-xs text-xs text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageThumbnailProps {
|
|
||||||
file: File;
|
|
||||||
previewUrl: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onRemove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageThumbnail({
|
|
||||||
file,
|
|
||||||
previewUrl,
|
|
||||||
disabled = false,
|
|
||||||
onRemove,
|
|
||||||
}: ImageThumbnailProps) {
|
|
||||||
return (
|
|
||||||
<div className="group relative size-20 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt={file.name}
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onRemove}
|
|
||||||
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoThumbnailProps {
|
|
||||||
file: File;
|
|
||||||
previewUrl: string;
|
|
||||||
duration: string | null;
|
|
||||||
disabled?: boolean;
|
|
||||||
onRemove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VideoThumbnail({
|
|
||||||
file,
|
|
||||||
previewUrl,
|
|
||||||
duration,
|
|
||||||
disabled = false,
|
|
||||||
onRemove,
|
|
||||||
}: VideoThumbnailProps) {
|
|
||||||
return (
|
|
||||||
<div className="group relative h-20 w-36 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
|
||||||
<video
|
|
||||||
src={previewUrl}
|
|
||||||
className="size-full object-cover"
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
aria-label={file.name}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
|
|
||||||
<span className="flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm">
|
|
||||||
<Play className="size-3.5 fill-current" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{duration ? (
|
|
||||||
<span className="pointer-events-none absolute bottom-1.5 right-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
|
||||||
{duration}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onRemove}
|
|
||||||
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RepairEvidenceFormProps {
|
|
||||||
ticketId: string;
|
|
||||||
defectClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepairEvidenceForm({
|
|
||||||
ticketId,
|
|
||||||
defectClass,
|
|
||||||
}: RepairEvidenceFormProps) {
|
|
||||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
|
||||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
|
||||||
const [videoDuration, setVideoDuration] = useState<string | null>(null);
|
|
||||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
|
||||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
|
||||||
const isSubmitting = submitRepairUploadMutation.isPending;
|
|
||||||
const canSubmit = Boolean(notes.trim() && videoFile);
|
|
||||||
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
|
||||||
setImagePreviews(urls);
|
|
||||||
return () => {
|
|
||||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
|
||||||
};
|
|
||||||
}, [imageFiles]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!videoFile) {
|
|
||||||
setVideoPreview(null);
|
|
||||||
setVideoDuration(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(videoFile);
|
|
||||||
setVideoPreview(url);
|
|
||||||
setVideoDuration(null);
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.preload = 'metadata';
|
|
||||||
video.onloadedmetadata = () => {
|
|
||||||
if (cancelled || !Number.isFinite(video.duration)) return;
|
|
||||||
setVideoDuration(formatVideoDuration(video.duration));
|
|
||||||
};
|
|
||||||
video.src = url;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
}, [videoFile]);
|
|
||||||
|
|
||||||
const addImages = (files: FileList | null) => {
|
|
||||||
if (!files?.length) return;
|
|
||||||
|
|
||||||
const remaining = MAX_IMAGES - imageFiles.length;
|
|
||||||
if (remaining <= 0) {
|
|
||||||
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextImages = [
|
|
||||||
...imageFiles,
|
|
||||||
...Array.from(files).slice(0, remaining),
|
|
||||||
];
|
|
||||||
setImageFiles(nextImages);
|
|
||||||
setError(
|
|
||||||
files.length > remaining
|
|
||||||
? `Only ${MAX_IMAGES} images can be uploaded.`
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectVideo = (files: FileList | null) => {
|
|
||||||
setVideoFile(files?.[0] ?? null);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeVideo = () => {
|
|
||||||
setVideoFile(null);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeImage = (index: number) => {
|
|
||||||
setImageFiles((current) =>
|
|
||||||
current.filter((_, itemIndex) => itemIndex !== index),
|
|
||||||
);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!notes.trim() || !videoFile) {
|
|
||||||
setError('Repair notes and a video are required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await submitRepairUploadMutation.mutateAsync({
|
|
||||||
defect_class: defectClass,
|
|
||||||
notes: notes.trim(),
|
|
||||||
video: videoFile,
|
|
||||||
images: imageFiles,
|
|
||||||
});
|
|
||||||
setNotes('');
|
|
||||||
setVideoFile(null);
|
|
||||||
setImageFiles([]);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openImagePicker = () => {
|
|
||||||
if (!isSubmitting && canAddMoreImages) {
|
|
||||||
imageInputRef.current?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openVideoPicker = () => {
|
|
||||||
if (!isSubmitting) {
|
|
||||||
videoInputRef.current?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
|
|
||||||
<Camera className="mt-0.5 size-5 shrink-0 text-primary" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-semibold">Instructions</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Upload clear photos and one video of the repaired area and provide
|
|
||||||
relevant details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/*"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
disabled={isSubmitting || !canAddMoreImages}
|
|
||||||
onChange={(event) => {
|
|
||||||
addImages(event.target.files);
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={videoInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="video/mp4,video/quicktime,video/x-msvideo,video/*"
|
|
||||||
className="hidden"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onChange={(event) => {
|
|
||||||
selectVideo(event.target.files);
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<MediaSectionHeader
|
|
||||||
icon={<Camera className="size-4" />}
|
|
||||||
title="Photos"
|
|
||||||
addLabel="Add Photos"
|
|
||||||
onAdd={openImagePicker}
|
|
||||||
disabled={isSubmitting || !canAddMoreImages}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
||||||
{imageFiles.length === 0 ? (
|
|
||||||
<EmptyMediaPlaceholder
|
|
||||||
icon={<ImageIcon className="size-5" />}
|
|
||||||
title="No photos added yet"
|
|
||||||
description="Tap to upload repair photos"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={openImagePicker}
|
|
||||||
className="min-h-[104px]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-w-min gap-3">
|
|
||||||
{imageFiles.map((file, index) => (
|
|
||||||
<ImageThumbnail
|
|
||||||
key={`${file.name}-${file.lastModified}-${index}`}
|
|
||||||
file={file}
|
|
||||||
previewUrl={imagePreviews[index]}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onRemove={() => removeImage(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MediaHint>
|
|
||||||
You can upload up to {MAX_IMAGES} images (JPG, PNG)
|
|
||||||
</MediaHint>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Video className="size-4 text-primary" />
|
|
||||||
<span className="text-sm font-semibold">Video</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{!videoFile || !videoPreview ? (
|
|
||||||
<EmptyMediaPlaceholder
|
|
||||||
icon={<Video className="size-5" />}
|
|
||||||
title="No video added yet"
|
|
||||||
description="Tap to upload a repair video"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={openVideoPicker}
|
|
||||||
className="min-h-[104px]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VideoThumbnail
|
|
||||||
file={videoFile}
|
|
||||||
previewUrl={videoPreview}
|
|
||||||
duration={videoDuration}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onRemove={removeVideo}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MediaHint>You can upload one video (MP4, MOV, AVI)</MediaHint>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="repair-notes">
|
|
||||||
Repair Notes <RequiredMark />
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Textarea
|
|
||||||
id="repair-notes"
|
|
||||||
value={notes}
|
|
||||||
onChange={(event) => {
|
|
||||||
setNotes(event.target.value.slice(0, MAX_NOTES));
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
placeholder="Describe the repair work completed, materials used, and any additional observations."
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="min-h-28 resize-none pb-8"
|
|
||||||
/>
|
|
||||||
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
|
|
||||||
{notes.length} / {MAX_NOTES}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full"
|
|
||||||
disabled={!canSubmit || isSubmitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Submitting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="size-4" />
|
|
||||||
Submit for Review
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Clock3, Loader2, Send } from 'lucide-react';
|
import { Clock3, Loader2, Send } from 'lucide-react';
|
||||||
|
|
||||||
import { DatePicker } from '@/components/form/DatePicker';
|
import { DatePicker } from '@/components/form/DatePicker';
|
||||||
|
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';
|
||||||
@@ -15,16 +16,24 @@ const MAX_REASON_LENGTH = 300;
|
|||||||
|
|
||||||
interface RequestExtensionFormProps {
|
interface RequestExtensionFormProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
onCancel: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RequestExtensionForm({
|
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
|
||||||
ticket,
|
const currentDueAt = ticket.worker?.due_at;
|
||||||
onCancel,
|
const currentDueDate = useMemo(
|
||||||
}: RequestExtensionFormProps) {
|
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
||||||
const currentDueDate = ticket.worker?.due_at
|
[currentDueAt],
|
||||||
? new Date(ticket.worker.due_at)
|
);
|
||||||
: undefined;
|
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 [requestedDueDate, setRequestedDueDate] = useState<Date>();
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const assignmentId = ticket.worker?.assignment_id;
|
const assignmentId = ticket.worker?.assignment_id;
|
||||||
@@ -53,7 +62,6 @@ export function RequestExtensionForm({
|
|||||||
});
|
});
|
||||||
setRequestedDueDate(undefined);
|
setRequestedDueDate(undefined);
|
||||||
setReason('');
|
setReason('');
|
||||||
onCancel();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,12 +89,17 @@ export function RequestExtensionForm({
|
|||||||
<Label>
|
<Label>
|
||||||
Requested Due Date <span className="text-destructive">*</span>
|
Requested Due Date <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<DatePicker
|
<DatePickerSimple
|
||||||
|
id="requested-due-date"
|
||||||
value={requestedDueDate}
|
value={requestedDueDate}
|
||||||
onChange={setRequestedDueDate}
|
onChange={setRequestedDueDate}
|
||||||
showTimeZone={false}
|
showLabel={false}
|
||||||
|
placeholder="Select requested due date"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
startMonth={earliestRequestedDate}
|
||||||
|
endMonth={latestRequestedMonth}
|
||||||
|
disabledDates={{ before: earliestRequestedDate }}
|
||||||
/>
|
/>
|
||||||
{requestedDueDate && !isRequestedDateValid ? (
|
{requestedDueDate && !isRequestedDateValid ? (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
@@ -120,20 +133,15 @@ export function RequestExtensionForm({
|
|||||||
This request will be sent to your manager for approval.
|
This request will be sent to your manager for approval.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
className="w-full"
|
||||||
variant="outline"
|
disabled={!canSubmit}
|
||||||
disabled={isPending}
|
onClick={handleSubmit}
|
||||||
onClick={onCancel}
|
>
|
||||||
>
|
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||||
Cancel
|
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
|
|
||||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
|
||||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
useReviewTicketExtensionMutation,
|
useReviewTicketExtensionMutation,
|
||||||
useTicketExtensionRequestQuery,
|
useTicketExtensionRequestQuery,
|
||||||
} from '../../../hooks/useTicketQueries';
|
} from '../../../hooks/useTicketQueries';
|
||||||
import { NoTicketAction } from './NoTicketAction';
|
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
const MAX_NOTE_LENGTH = 300;
|
const MAX_NOTE_LENGTH = 300;
|
||||||
@@ -208,7 +207,7 @@ export function ReviewExtensionRequestAction({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!assignmentId || extensionRequestQuery.isError) {
|
if (!assignmentId || extensionRequestQuery.isError) {
|
||||||
return <NoTicketAction />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extensionRequestQuery.isLoading) {
|
if (extensionRequestQuery.isLoading) {
|
||||||
@@ -226,7 +225,7 @@ export function ReviewExtensionRequestAction({
|
|||||||
? getPendingRequest(extensionRequestQuery.data)
|
? getPendingRequest(extensionRequestQuery.data)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!pendingRequest) return <NoTicketAction />;
|
if (!pendingRequest) return null;
|
||||||
|
|
||||||
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
|
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Check, ClipboardCheck, X } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
|
|
||||||
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
|
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
|
||||||
|
|
||||||
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const [reviewComment, setReviewComment] = useState('');
|
|
||||||
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
|
||||||
}, [ticket]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Comment</Label>
|
|
||||||
<Textarea
|
|
||||||
value={reviewComment}
|
|
||||||
onChange={(event) => setReviewComment(event.target.value)}
|
|
||||||
placeholder="Repair verified"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
reviewRepairMutation.mutate({
|
|
||||||
defect_class: ticket.defect_class,
|
|
||||||
action: 'approve',
|
|
||||||
comment: reviewComment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Check />
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
reviewRepairMutation.mutate({
|
|
||||||
defect_class: ticket.defect_class,
|
|
||||||
action: 'reject',
|
|
||||||
comment: reviewComment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TicketActionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Wrench } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
|
|
||||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
|
||||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
|
||||||
|
|
||||||
type RepairActionTab = 'repair-proof' | 'request-extension';
|
|
||||||
|
|
||||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TicketActionCard icon={Wrench} title="Repair Proof">
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={(value) => setActiveTab(value as RepairActionTab)}
|
|
||||||
className="gap-5"
|
|
||||||
>
|
|
||||||
<TabsList variant="line" className="grid h-auto w-full grid-cols-2 p-0">
|
|
||||||
<TabsTrigger value="repair-proof" className="pb-3">
|
|
||||||
Upload Repair Proof
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="request-extension" className="pb-3">
|
|
||||||
Request Extension
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="repair-proof">
|
|
||||||
<RepairEvidenceForm
|
|
||||||
ticketId={ticket.id}
|
|
||||||
defectClass={ticket.defect_class}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="request-extension">
|
|
||||||
<TicketExtensionRequestPanel
|
|
||||||
ticket={ticket}
|
|
||||||
onCancelRequest={() => setActiveTab('repair-proof')}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</TicketActionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ import { formatDate } from '@/utils/date';
|
|||||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||||
import { RequestExtensionForm } from './RequestExtensionForm';
|
import { RequestExtensionForm } from './RequestExtensionForm';
|
||||||
|
|
||||||
interface TicketExtensionRequestPanelProps {
|
|
||||||
ticket: TicketDetail;
|
|
||||||
onCancelRequest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailRowProps {
|
interface DetailRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -192,8 +187,9 @@ function ExtensionRequestDetails({
|
|||||||
|
|
||||||
export function TicketExtensionRequestPanel({
|
export function TicketExtensionRequestPanel({
|
||||||
ticket,
|
ticket,
|
||||||
onCancelRequest,
|
}: {
|
||||||
}: TicketExtensionRequestPanelProps) {
|
ticket: TicketDetail;
|
||||||
|
}) {
|
||||||
const assignmentId = ticket.worker?.assignment_id;
|
const assignmentId = ticket.worker?.assignment_id;
|
||||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||||
ticket.id,
|
ticket.id,
|
||||||
@@ -241,7 +237,7 @@ export function TicketExtensionRequestPanel({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!latestRequest) {
|
if (!latestRequest) {
|
||||||
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
|
return <RequestExtensionForm ticket={ticket} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ExtensionRequestDetails request={latestRequest} />;
|
return <ExtensionRequestDetails request={latestRequest} />;
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ImageIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
|
|
||||||
import { ReportSurface } from './repairReportUtils';
|
|
||||||
|
|
||||||
const MAX_MEDIA_SLOTS = 5;
|
|
||||||
|
|
||||||
const imageBadgeStyles = [
|
|
||||||
'bg-primary/90 text-primary-foreground',
|
|
||||||
'bg-secondary text-secondary-foreground',
|
|
||||||
'bg-accent text-accent-foreground',
|
|
||||||
'bg-muted text-muted-foreground',
|
|
||||||
'bg-primary/70 text-primary-foreground',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function RepairProofImagesGrid({
|
|
||||||
imageUrls,
|
|
||||||
submittedAt,
|
|
||||||
layout = 'row',
|
|
||||||
}: {
|
|
||||||
imageUrls: string[];
|
|
||||||
submittedAt: string | null;
|
|
||||||
layout?: 'row' | 'sidebar';
|
|
||||||
}) {
|
|
||||||
const imageSlots = Array.from(
|
|
||||||
{ length: MAX_MEDIA_SLOTS },
|
|
||||||
(_, index) => imageUrls[index] ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid gap-3',
|
|
||||||
layout === 'sidebar'
|
|
||||||
? 'grid-cols-2 auto-rows-fr'
|
|
||||||
: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{imageSlots.map((url, index) => (
|
|
||||||
<RepairProofImageSlot
|
|
||||||
key={index}
|
|
||||||
url={url}
|
|
||||||
index={index}
|
|
||||||
submittedAt={submittedAt}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RepairProofImageSlot({
|
|
||||||
url,
|
|
||||||
index,
|
|
||||||
submittedAt,
|
|
||||||
}: {
|
|
||||||
url: string | null;
|
|
||||||
index: number;
|
|
||||||
submittedAt: string | null;
|
|
||||||
}) {
|
|
||||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return (
|
|
||||||
<ReportSurface className="space-y-2 p-2">
|
|
||||||
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/10 text-muted-foreground/70">
|
|
||||||
<ImageIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
<p className="truncate px-1 text-[11px] text-muted-foreground/70">
|
|
||||||
No image added
|
|
||||||
</p>
|
|
||||||
</ReportSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<ReportSurface className="space-y-2 p-2">
|
|
||||||
<Skeleton className="aspect-4/3 w-full rounded-lg" />
|
|
||||||
<Skeleton className="h-3 w-16" />
|
|
||||||
</ReportSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !objectUrl) {
|
|
||||||
return (
|
|
||||||
<ReportSurface className="space-y-2 p-2">
|
|
||||||
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed bg-muted/10 text-xs text-muted-foreground">
|
|
||||||
Unavailable
|
|
||||||
</div>
|
|
||||||
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
|
||||||
Image {index + 1}
|
|
||||||
</p>
|
|
||||||
</ReportSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReportSurface className="space-y-2 p-2">
|
|
||||||
<a
|
|
||||||
href={objectUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="group relative block overflow-hidden rounded-lg border bg-muted/20"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={objectUrl}
|
|
||||||
alt={`Repair proof ${index + 1}`}
|
|
||||||
className="aspect-4/3 w-full object-cover transition-transform group-hover:scale-[1.02]"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
|
||||||
{submittedAt ? formatDate(submittedAt) : `Image ${index + 1}`}
|
|
||||||
</p>
|
|
||||||
</ReportSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { FileText, MessageSquareQuote } from 'lucide-react';
|
|
||||||
|
|
||||||
import { PersonInfo } from '@/components/person-avatar';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
|
|
||||||
import { EmptyPanel } from './repairReportUtils';
|
|
||||||
|
|
||||||
function MetaLabel({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportField({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string | null | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<MetaLabel>{label}</MetaLabel>
|
|
||||||
<p className="text-sm font-semibold text-foreground">{value || '-'}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const repair = ticket.repair;
|
|
||||||
|
|
||||||
if (!repair) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<FileText className="size-5 text-primary" />
|
|
||||||
Repair Report
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<EmptyPanel
|
|
||||||
icon={MessageSquareQuote}
|
|
||||||
title="No repair report submitted yet"
|
|
||||||
description="Repair details will appear here once the assigned worker submits evidence."
|
|
||||||
className="py-10"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="sm:grid-cols-[1fr_auto]">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<FileText className="size-5 text-primary" />
|
|
||||||
Repair Report
|
|
||||||
</CardTitle>
|
|
||||||
{repair.submitted_at ? (
|
|
||||||
<span className="text-xs text-muted-foreground italic sm:col-start-2 sm:row-start-1">
|
|
||||||
Completed {formatDate(repair.submitted_at)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<MetaLabel>Technician</MetaLabel>
|
|
||||||
<PersonInfo person={ticket.worker} />
|
|
||||||
</div>
|
|
||||||
<ReportField
|
|
||||||
label="Submitted At"
|
|
||||||
value={
|
|
||||||
repair.submitted_at ? formatDate(repair.submitted_at) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<MetaLabel>Repair Summary</MetaLabel>
|
|
||||||
{repair.notes?.trim() ? (
|
|
||||||
<div className="rounded-lg border bg-muted/15 px-4 py-3">
|
|
||||||
<p className="text-sm leading-relaxed text-foreground/90">
|
|
||||||
{repair.notes}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyPanel
|
|
||||||
icon={MessageSquareQuote}
|
|
||||||
title="No repair notes added yet"
|
|
||||||
className="py-6"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ShieldCheck } from 'lucide-react';
|
|
||||||
|
|
||||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
|
|
||||||
export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const originalVideoUrl = ticket.video?.url ?? null;
|
|
||||||
const repairVideoUrl = ticket.repair?.proof_video_url ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<ShieldCheck className="size-5 text-primary" />
|
|
||||||
Repair Verification
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<ComparisonVideoItem
|
|
||||||
tooltip={ticket.video?.name ?? 'Original inspection video'}
|
|
||||||
videoLabel="Before"
|
|
||||||
videoUrl={originalVideoUrl}
|
|
||||||
meta="AI detection video"
|
|
||||||
emptyTitle="No original video available"
|
|
||||||
emptyDescription="The source inspection video is not attached to this ticket."
|
|
||||||
/>
|
|
||||||
<ComparisonVideoItem
|
|
||||||
tooltip="Contractor repair evidence video"
|
|
||||||
videoLabel="After"
|
|
||||||
videoUrl={repairVideoUrl}
|
|
||||||
meta="Contractor evidence video"
|
|
||||||
emptyTitle={
|
|
||||||
ticket.repair
|
|
||||||
? 'No repair video uploaded'
|
|
||||||
: 'No evidence submitted yet'
|
|
||||||
}
|
|
||||||
emptyDescription={
|
|
||||||
ticket.repair
|
|
||||||
? 'The assigned worker has not uploaded a repair video yet.'
|
|
||||||
: 'Repair video will appear here once evidence is submitted.'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComparisonVideoItem({
|
|
||||||
tooltip,
|
|
||||||
videoLabel,
|
|
||||||
videoUrl,
|
|
||||||
meta,
|
|
||||||
emptyTitle,
|
|
||||||
emptyDescription,
|
|
||||||
}: {
|
|
||||||
tooltip: string;
|
|
||||||
videoLabel: string;
|
|
||||||
videoUrl: string | null;
|
|
||||||
meta: string;
|
|
||||||
emptyTitle: string;
|
|
||||||
emptyDescription: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div title={tooltip}>
|
|
||||||
<ProtectedVideoPlayer
|
|
||||||
url={videoUrl}
|
|
||||||
label={videoLabel}
|
|
||||||
meta={meta}
|
|
||||||
emptyTitle={emptyTitle}
|
|
||||||
emptyDescription={emptyDescription}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<p className="text-[11px] font-semibold tracking-[0.12em] text-foreground uppercase">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReportSurface({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn('rounded-lg border bg-muted/15 p-3', className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyPanel({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="mb-3 size-6 text-muted-foreground" />
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
|
||||||
{description ? (
|
|
||||||
<p className="mt-1 max-w-xs text-xs text-muted-foreground/80">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useQueries } from '@tanstack/react-query';
|
||||||
|
import LightGallery from 'lightgallery/react';
|
||||||
|
import lgThumbnail from 'lightgallery/plugins/thumbnail';
|
||||||
|
import lgZoom from 'lightgallery/plugins/zoom';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { protectedMediaService } from '@/services/api';
|
||||||
|
|
||||||
|
interface RepairProofGalleryProps {
|
||||||
|
imageUrls: string[];
|
||||||
|
maxVisibleImages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE_IMAGES = 8;
|
||||||
|
|
||||||
|
export function RepairProofGallery({
|
||||||
|
imageUrls,
|
||||||
|
maxVisibleImages = MAX_VISIBLE_IMAGES,
|
||||||
|
}: RepairProofGalleryProps) {
|
||||||
|
const queries = useQueries({
|
||||||
|
queries: imageUrls.map((url) => ({
|
||||||
|
queryKey: ['protected-media', 'v2', url],
|
||||||
|
queryFn: () => protectedMediaService.getBlob(url),
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 1000 * 60 * 30,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const blobs = queries.map((query) => query.data);
|
||||||
|
const objectUrls = useMemo(
|
||||||
|
() => blobs.map((blob) => (blob ? URL.createObjectURL(blob) : null)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[...blobs],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
objectUrls.forEach((url) => {
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[objectUrls],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (queries.some((query) => query.isLoading)) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-lg">
|
||||||
|
{imageUrls.slice(0, maxVisibleImages).map((url, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={`${url}-${index}`}
|
||||||
|
className="aspect-square rounded-lg"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = objectUrls.flatMap((url, index) =>
|
||||||
|
url ? [{ url, originalIndex: index }] : [],
|
||||||
|
);
|
||||||
|
const visibleCount = Math.min(maxVisibleImages, images.length);
|
||||||
|
const overflowCount = images.length - visibleCount;
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Repair images are unavailable.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightGallery
|
||||||
|
elementClassNames="grid grid-cols-4 gap-1 overflow-hidden rounded-lg"
|
||||||
|
mode="lg-fade"
|
||||||
|
speed={400}
|
||||||
|
plugins={[lgThumbnail, lgZoom]}
|
||||||
|
thumbnail
|
||||||
|
download={false}
|
||||||
|
mobileSettings={{ controls: true, showCloseIcon: true, download: false }}
|
||||||
|
>
|
||||||
|
{images.map(({ url, originalIndex }, index) => {
|
||||||
|
const isVisible = index < visibleCount;
|
||||||
|
const isOverflowTile = overflowCount > 0 && index === visibleCount - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={imageUrls[originalIndex]}
|
||||||
|
data-src={url}
|
||||||
|
data-sub-html={`<p>Repair evidence ${originalIndex + 1}</p>`}
|
||||||
|
className={isVisible ? 'group relative aspect-square' : 'hidden'}
|
||||||
|
aria-label={
|
||||||
|
isOverflowTile
|
||||||
|
? `View all ${images.length} repair images`
|
||||||
|
: `View repair image ${originalIndex + 1} of ${images.length}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Blob URLs cannot be handled by next/image. */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`Repair evidence ${originalIndex + 1}`}
|
||||||
|
className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90"
|
||||||
|
/>
|
||||||
|
{isOverflowTile ? (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center bg-black/55 px-2 text-center text-sm font-semibold text-white transition-colors group-hover:bg-black/65 sm:text-base">
|
||||||
|
+{overflowCount} more
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LightGallery>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
|
|
||||||
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 { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
||||||
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||||
@@ -59,31 +58,30 @@ 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">
|
||||||
{overview ? (
|
<div className="xl:shrink-0">
|
||||||
<TicketDetailHeader ticket={overview} />
|
{overview ? (
|
||||||
) : (
|
<TicketDetailHeader ticket={overview} />
|
||||||
<TicketOverviewHeaderSkeleton />
|
) : (
|
||||||
)}
|
<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">
|
||||||
{overview ? (
|
{overview ? (
|
||||||
<TicketOverviewCard ticket={overview} defectClass={defectClass} />
|
<TicketOverviewCard ticket={overview} />
|
||||||
) : (
|
) : (
|
||||||
<TicketOverviewCardSkeleton />
|
<TicketOverviewCardSkeleton />
|
||||||
)}
|
)}
|
||||||
|
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
|
||||||
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
||||||
{!isClassDetailLoading && classDetail ? (
|
{!isClassDetailLoading && classDetail ? (
|
||||||
<>
|
<TicketHistoryCard ticket={classDetail} />
|
||||||
<TicketRepairReportCard ticket={classDetail} />
|
|
||||||
<TicketHistoryCard ticket={classDetail} />
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 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">
|
||||||
<TicketDefectClassTabs ticketId={ticketId} />
|
|
||||||
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
|
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
|
||||||
{!isClassDetailLoading && classDetail ? (
|
{!isClassDetailLoading && classDetail ? (
|
||||||
<TicketStatusActions ticket={classDetail} />
|
<TicketStatusActions ticket={classDetail} />
|
||||||
|
|||||||
@@ -1,20 +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,
|
||||||
|
DiscardDetectionPayload,
|
||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
|
ReviewDetectionRepairProofPayload,
|
||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
ReviewRepairPayload,
|
|
||||||
SubmitRepairPayload,
|
|
||||||
SubmitRepairUploadPayload,
|
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
|
DetectionProofStatus,
|
||||||
|
VideoDetectionsParams,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
import { ticketKeys } from '../queries/ticketKeys';
|
import { ticketKeys } from '../queries/ticketKeys';
|
||||||
@@ -85,6 +91,155 @@ 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(
|
export function useTicketExtensionRequestQuery(
|
||||||
ticketId: string | undefined,
|
ticketId: string | undefined,
|
||||||
assignmentId: number | undefined,
|
assignmentId: number | undefined,
|
||||||
@@ -139,42 +294,6 @@ export function useAssignTicketMutation(ticketId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubmitRepairMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: SubmitRepairPayload) =>
|
|
||||||
ticketService.submitRepair(ticketId, payload),
|
|
||||||
onSuccess: (ticket) => {
|
|
||||||
toast.success('Repair submitted');
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.classDetail(ticketId, ticket.defect_class),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to submit repair'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubmitRepairUploadMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
|
||||||
ticketService.submitRepairUpload(ticketId, payload),
|
|
||||||
onSuccess: (ticket, payload) => {
|
|
||||||
toast.success('Repair evidence uploaded');
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to upload repair evidence'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRequestTicketExtensionMutation(
|
export function useRequestTicketExtensionMutation(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
assignmentId: number | undefined,
|
assignmentId: number | undefined,
|
||||||
@@ -233,26 +352,6 @@ export function useReviewTicketExtensionMutation(
|
|||||||
onError: () => toast.error('Failed to review extension request'),
|
onError: () => toast.error('Failed to review extension request'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function useReviewRepairMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: ReviewRepairPayload) =>
|
|
||||||
ticketService.reviewRepair(ticketId, payload),
|
|
||||||
onSuccess: (ticket, payload) => {
|
|
||||||
toast.success(
|
|
||||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
|
||||||
);
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to review repair'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCloseTicketMutation(ticketId: string) {
|
export function useCloseTicketMutation(ticketId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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,
|
||||||
@@ -9,6 +9,21 @@ export const ticketKeys = {
|
|||||||
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
||||||
classDetail: (ticketId: string, defectClass: string) =>
|
classDetail: (ticketId: string, defectClass: string) =>
|
||||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
[...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.details(), ticketId, 'defect-classes'] as const,
|
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
Eye,
|
|
||||||
MoreVertical,
|
|
||||||
PlayCircle,
|
|
||||||
RotateCcw,
|
|
||||||
Ticket,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import type { UploadListItem } from '@/types';
|
|
||||||
|
|
||||||
interface UploadActionsMenuProps {
|
|
||||||
upload: UploadListItem;
|
|
||||||
onViewVideo: (upload: UploadListItem) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UploadActionsMenu({
|
|
||||||
upload,
|
|
||||||
onViewVideo,
|
|
||||||
}: UploadActionsMenuProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<MoreVertical className="size-4" />
|
|
||||||
<span className="sr-only">Open actions</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{upload.raw_video_url ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onViewVideo(upload);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlayCircle className="size-4" />
|
|
||||||
View Video
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Eye /> View Details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<RotateCcw /> Retry Analysis
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={!upload.ticket_id}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (upload.ticket_id) {
|
|
||||||
router.push(`/ticket/${upload.ticket_id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ticket /> Open Ticket
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem variant="destructive">
|
|
||||||
<Trash2 /> Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ExternalLink, Ticket, UserRound } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
|
import { ROUTES } from '@/utils/routes';
|
||||||
|
import type { UploadListItem } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
import { UploadResultCell } from './UploadResultCell';
|
||||||
|
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||||
|
|
||||||
|
interface UploadDetailsDialogProps {
|
||||||
|
upload: UploadListItem | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadDetailsDialog({
|
||||||
|
upload,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: UploadDetailsDialogProps) {
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
const canOpenTicket = hasPermission(PERMISSIONS.TICKET.READ);
|
||||||
|
const details = [
|
||||||
|
{ label: 'Project', value: upload?.project },
|
||||||
|
{ label: 'Package', value: upload?.package },
|
||||||
|
{ label: 'Segment', value: upload?.segment },
|
||||||
|
{ label: 'Chainage', value: upload?.chainage },
|
||||||
|
{ label: 'Uploaded', value: formatDate(upload?.uploaded_at) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-6xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Upload Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Preview the inspection video and review its processing details.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,0.8fr)]">
|
||||||
|
<section className="min-w-0 space-y-2">
|
||||||
|
<p className="text-sm font-medium">Video Preview</p>
|
||||||
|
<ProtectedVideoPlayer
|
||||||
|
url={open ? upload?.raw_video_url : undefined}
|
||||||
|
label={upload?.video_name}
|
||||||
|
className="rounded-lg"
|
||||||
|
unavailableTitle="Video preview unavailable"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="min-w-0 space-y-4 rounded-lg border bg-muted/15 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Status</p>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
{upload ? (
|
||||||
|
<UploadStatusBadge
|
||||||
|
status={upload.status}
|
||||||
|
progress={upload.progress}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-muted-foreground">Detections</p>
|
||||||
|
<Badge variant="secondary" className="mt-1.5 tabular-nums">
|
||||||
|
{upload?.total_detections ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">Result</p>
|
||||||
|
{upload ? <UploadResultCell item={upload} /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-t pt-4">
|
||||||
|
{details.map((detail) => (
|
||||||
|
<div
|
||||||
|
key={detail.label}
|
||||||
|
className={
|
||||||
|
detail.label === 'Uploaded' ? 'col-span-2' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<dt className="text-xs text-muted-foreground">
|
||||||
|
{detail.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 truncate text-sm font-medium">
|
||||||
|
{detail.value || '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 items-start gap-2 border-t pt-4">
|
||||||
|
<UserRound className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{upload?.uploaded_by.name || 'Unknown uploader'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{upload?.uploaded_by.email || 'No email available'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
{upload?.ticket_id && canOpenTicket ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={ROUTES.TICKET_DETAIL(upload.ticket_id)}>
|
||||||
|
<Ticket />
|
||||||
|
Open Ticket
|
||||||
|
<ExternalLink />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ export function UploadFileDropzone({
|
|||||||
handleFiles(event.dataTransfer.files);
|
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>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import type { UploadListItem } from '@/types';
|
import type { UploadListItem } from '@/types';
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
import { UploadActionsMenu } from './UploadActionsMenu';
|
|
||||||
import { UploadResultCell } from './UploadResultCell';
|
import { UploadResultCell } from './UploadResultCell';
|
||||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||||
|
|
||||||
@@ -22,9 +21,7 @@ function resolveThumbnailUrl(value: string | null) {
|
|||||||
return baseUrl ? `${baseUrl}${path}` : path;
|
return baseUrl ? `${baseUrl}${path}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUploadListColumns(
|
export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
|
||||||
onViewVideo: (upload: UploadListItem) => void,
|
|
||||||
): ColumnDef<UploadListItem>[] {
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -39,18 +36,14 @@ export function useUploadListColumns(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex aspect-video w-20 items-center justify-center overflow-hidden rounded-md border bg-muted bg-cover bg-center"
|
className="flex aspect-video w-20 items-center justify-center overflow-hidden rounded-lg border bg-muted bg-cover bg-center"
|
||||||
style={
|
style={
|
||||||
thumbnail
|
thumbnail
|
||||||
? { backgroundImage: `url("${thumbnail}")` }
|
? { backgroundImage: `url("${thumbnail}")` }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
role={thumbnail ? 'img' : undefined}
|
role={thumbnail ? 'img' : undefined}
|
||||||
aria-label={
|
aria-label={thumbnail ? 'Upload thumbnail' : undefined}
|
||||||
thumbnail
|
|
||||||
? `Thumbnail for ${row.original.video_name}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{!thumbnail ? (
|
{!thumbnail ? (
|
||||||
<Video className="size-5 text-muted-foreground" />
|
<Video className="size-5 text-muted-foreground" />
|
||||||
@@ -59,14 +52,6 @@ export function useUploadListColumns(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'video_name',
|
|
||||||
header: 'Video Name',
|
|
||||||
size: 150,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-medium">{row.original.video_name}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'uploaded_by',
|
id: 'uploaded_by',
|
||||||
header: 'Uploader',
|
header: 'Uploader',
|
||||||
@@ -145,24 +130,7 @@ export function useUploadListColumns(
|
|||||||
meta: { disableTruncate: true },
|
meta: { disableTruncate: true },
|
||||||
cell: ({ row }) => <UploadResultCell item={row.original} />,
|
cell: ({ row }) => <UploadResultCell item={row.original} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
|
||||||
size: 64,
|
|
||||||
minSize: 64,
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
meta: { disableTruncate: true },
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<UploadActionsMenu
|
|
||||||
upload={row.original}
|
|
||||||
onViewVideo={onViewVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[onViewVideo],
|
[],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CircleX, TicketCheck } from 'lucide-react';
|
import { CircleMinus, CircleX, TicketCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { UploadListItem } from '@/types';
|
import type { UploadListItem } from '@/types';
|
||||||
@@ -30,5 +30,17 @@ export function UploadResultCell({ item }: { item: UploadListItem }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.result === 'no_defects_found') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-500">
|
||||||
|
<CircleMinus className="size-3.5" /> No Defects Found
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{item.total_detections ?? 0} issues detected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <span className="text-muted-foreground">—</span>;
|
return <span className="text-muted-foreground">—</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UploadTableProps {
|
|||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
onUploadClick: (upload: UploadListItem) => void;
|
||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ export function UploadTable({
|
|||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
|
onUploadClick,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
}: UploadTableProps) {
|
}: UploadTableProps) {
|
||||||
@@ -33,6 +35,7 @@ export function UploadTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle="No uploads found"
|
emptyTitle="No uploads found"
|
||||||
emptyDescription="Upload your first road inspection video."
|
emptyDescription="Upload your first road inspection video."
|
||||||
|
onRowClick={onUploadClick}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import type { UploadListItem } from '@/types';
|
|
||||||
|
|
||||||
interface UploadVideoDialogProps {
|
|
||||||
upload: UploadListItem | null;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UploadVideoDialog({
|
|
||||||
upload,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: UploadVideoDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="gap-3 p-3 sm:max-w-5xl sm:p-4">
|
|
||||||
<DialogHeader className="min-w-0 pr-8">
|
|
||||||
<DialogTitle className="truncate text-left text-base">
|
|
||||||
Video Preview
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="truncate text-xs">
|
|
||||||
{upload?.video_name ?? 'Uploaded video'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<ProtectedVideoPlayer
|
|
||||||
url={open ? upload?.raw_video_url : undefined}
|
|
||||||
className="rounded-lg"
|
|
||||||
unavailableTitle="Video preview unavailable"
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import type { UploadListItem } from '@/types';
|
|||||||
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
||||||
import { useUploadListColumns } from './components/UploadListColumns';
|
import { useUploadListColumns } from './components/UploadListColumns';
|
||||||
import { UploadTable } from './components/UploadTable';
|
import { UploadTable } from './components/UploadTable';
|
||||||
import { UploadVideoDialog } from './components/UploadVideoDialog';
|
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
|
||||||
import { useUploadFilters } from './hooks/useUploadFilters';
|
import { useUploadFilters } from './hooks/useUploadFilters';
|
||||||
import { useUploadListEvents } from './hooks/useUploadListEvents';
|
import { useUploadListEvents } from './hooks/useUploadListEvents';
|
||||||
import { useUploadsQuery } from './hooks/useUploadQueries';
|
import { useUploadsQuery } from './hooks/useUploadQueries';
|
||||||
@@ -20,13 +20,15 @@ export default function UploadPage() {
|
|||||||
const uploadsQuery = useUploadsQuery({ skip, limit });
|
const uploadsQuery = useUploadsQuery({ skip, limit });
|
||||||
useUploadListEvents();
|
useUploadListEvents();
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [videoUpload, setVideoUpload] = useState<UploadListItem | null>(null);
|
const [selectedUpload, setSelectedUpload] = useState<UploadListItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const uploads = uploadsQuery.data?.items ?? [];
|
const uploads = uploadsQuery.data?.items ?? [];
|
||||||
const total = uploadsQuery.data?.total ?? 0;
|
const total = uploadsQuery.data?.total ?? 0;
|
||||||
const isLoading = uploadsQuery.isLoading;
|
const isLoading = uploadsQuery.isLoading;
|
||||||
|
|
||||||
const columns = useUploadListColumns(setVideoUpload);
|
const columns = useUploadListColumns();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -50,17 +52,18 @@ export default function UploadPage() {
|
|||||||
skip={skip}
|
skip={skip}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
total={total}
|
total={total}
|
||||||
|
onUploadClick={setSelectedUpload}
|
||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||||
<UploadVideoDialog
|
<UploadDetailsDialog
|
||||||
upload={videoUpload}
|
upload={selectedUpload}
|
||||||
open={Boolean(videoUpload)}
|
open={Boolean(selectedUpload)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setVideoUpload(null);
|
if (!open) setSelectedUpload(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,6 +121,10 @@
|
|||||||
--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 {
|
||||||
@@ -133,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-none;
|
@apply outline-none border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -143,6 +149,12 @@
|
|||||||
.scroll-stable {
|
.scroll-stable {
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
button:not(:disabled), [role="button"]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detection-video-player .vds-slider-preview {
|
.detection-video-player .vds-slider-preview {
|
||||||
@@ -152,4 +164,4 @@
|
|||||||
.detection-video-player
|
.detection-video-player
|
||||||
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
|
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -1,12 +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/theme.css';
|
||||||
import '@vidstack/react/player/styles/default/layouts/video.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',
|
||||||
@@ -33,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>
|
||||||
@@ -46,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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
66
src/components/annotation/boundingBoxOverlay.tsx
Normal file
66
src/components/annotation/boundingBoxOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,17 +18,23 @@ 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';
|
||||||
@@ -41,6 +47,7 @@ function isRouteActive(pathname: string, path?: string) {
|
|||||||
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 = {
|
||||||
@@ -48,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
|
||||||
@@ -75,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 />
|
||||||
@@ -83,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,
|
||||||
@@ -159,4 +193,3 @@ function SidebarNavItem({
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
|
|
||||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
|
||||||
import type { PermissionInput } from '@/hooks/usePermissions';
|
import type { PermissionInput } from '@/hooks/usePermissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
|
|
||||||
@@ -211,8 +210,8 @@ function DataTableContent<TData, TValue>({
|
|||||||
},
|
},
|
||||||
manualPagination: !!pagination,
|
manualPagination: !!pagination,
|
||||||
manualSorting: isManualSorting,
|
manualSorting: isManualSorting,
|
||||||
pageCount: pagination?.totalItems
|
pageCount: pagination
|
||||||
? Math.ceil(pagination.totalItems / pagination.limit)
|
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
|
||||||
: -1,
|
: -1,
|
||||||
state: {
|
state: {
|
||||||
rowSelection,
|
rowSelection,
|
||||||
@@ -247,8 +246,8 @@ function DataTableContent<TData, TValue>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<>
|
||||||
<section className="space-y-4 rounded-lg border bg-card p-4">
|
<section className="space-y-3">
|
||||||
<TopHeader
|
<TopHeader
|
||||||
title={onAddNew ? title : undefined}
|
title={onAddNew ? title : undefined}
|
||||||
itemCount={pagination?.totalItems ?? data.length}
|
itemCount={pagination?.totalItems ?? data.length}
|
||||||
@@ -260,9 +259,9 @@ function DataTableContent<TData, TValue>({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div className="overflow-hidden rounded-lg border bg-background">
|
||||||
<Table
|
<Table
|
||||||
containerClassName="max-h-[calc(100vh-350px)] overflow-y-auto"
|
containerClassName="max-h-[calc(100vh-350px)] overflow-auto"
|
||||||
className="table-fixed border-separate border-spacing-0"
|
className="table-fixed border-separate border-spacing-0"
|
||||||
style={{ width: '100%', minWidth: table.getTotalSize() }}
|
style={{ width: '100%', minWidth: table.getTotalSize() }}
|
||||||
>
|
>
|
||||||
@@ -274,7 +273,7 @@ function DataTableContent<TData, TValue>({
|
|||||||
{columns.map((_, colIdx) => (
|
{columns.map((_, colIdx) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
className="border-b border-border"
|
className="h-13 border-b border-border px-4 py-3"
|
||||||
>
|
>
|
||||||
<Skeleton className="h-4 w-full max-w-35" />
|
<Skeleton className="h-4 w-full max-w-35" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -291,7 +290,9 @@ function DataTableContent<TData, TValue>({
|
|||||||
const truncate = shouldTruncateCell(cell.column);
|
const truncate = shouldTruncateCell(cell.column);
|
||||||
const pinned = cell.column.getIsPinned();
|
const pinned = cell.column.getIsPinned();
|
||||||
const isActionsCell = cell.column.id === 'actions';
|
const isActionsCell = cell.column.id === 'actions';
|
||||||
const isRowClickable = Boolean(onRowClick && !isActionsCell);
|
const isRowClickable = Boolean(
|
||||||
|
onRowClick && !isActionsCell,
|
||||||
|
);
|
||||||
const content = flexRender(
|
const content = flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext(),
|
||||||
@@ -307,8 +308,8 @@ function DataTableContent<TData, TValue>({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-0 overflow-hidden border-b border-border',
|
'h-13 max-w-0 overflow-hidden border-b border-border px-4 py-3 text-sm',
|
||||||
pinned && 'bg-card',
|
pinned && 'bg-background',
|
||||||
isRowClickable && 'cursor-pointer',
|
isRowClickable && 'cursor-pointer',
|
||||||
isActionsCell && 'cursor-default',
|
isActionsCell && 'cursor-default',
|
||||||
)}
|
)}
|
||||||
@@ -346,6 +347,6 @@ function DataTableContent<TData, TValue>({
|
|||||||
onPageSizeChange={pagination?.onLimitChange}
|
onPageSizeChange={pagination?.onLimitChange}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</TooltipProvider>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
129
src/components/form/DatePickerSimple.tsx
Normal file
129
src/components/form/DatePickerSimple.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { Matcher } from 'react-day-picker';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CalendarGridPicker } from '@/components/ui/calendar-grid-picker';
|
||||||
|
import { FormField } from '@/components/form/FormField';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface DatePickerSimpleProps {
|
||||||
|
value?: Date;
|
||||||
|
defaultValue?: Date;
|
||||||
|
onChange?: (date: Date | undefined) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
showLabel?: boolean;
|
||||||
|
startMonth?: Date;
|
||||||
|
endMonth?: Date;
|
||||||
|
disabledDates?: Matcher | Matcher[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setHours(0, 0, 0, 0);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDateToRange(date: Date, min: Date, max: Date) {
|
||||||
|
if (date < min) return min;
|
||||||
|
if (date > max) return max;
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatePickerSimple(props: DatePickerSimpleProps) {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
label = 'Date of birth',
|
||||||
|
placeholder = 'Select date',
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
id = 'date',
|
||||||
|
showLabel = true,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
disabledDates,
|
||||||
|
} = props;
|
||||||
|
const isControlled = Object.hasOwn(props, 'value');
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [internalDate, setInternalDate] = React.useState<Date | undefined>(
|
||||||
|
defaultValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedDate = isControlled ? value : internalDate;
|
||||||
|
const today = React.useMemo(() => startOfDay(new Date()), []);
|
||||||
|
const resolvedStartMonth = React.useMemo(
|
||||||
|
() => startMonth ?? new Date(today.getFullYear() - 100, 0),
|
||||||
|
[startMonth, today],
|
||||||
|
);
|
||||||
|
const resolvedEndMonth = React.useMemo(
|
||||||
|
() => endMonth ?? today,
|
||||||
|
[endMonth, today],
|
||||||
|
);
|
||||||
|
const initialMonth = selectedDate
|
||||||
|
? startOfDay(selectedDate)
|
||||||
|
: clampDateToRange(today, resolvedStartMonth, resolvedEndMonth);
|
||||||
|
|
||||||
|
const handleSelect = (date: Date | undefined) => {
|
||||||
|
if (!isControlled) setInternalDate(date);
|
||||||
|
onChange?.(date);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const picker = (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
id={id}
|
||||||
|
disabled={disabled}
|
||||||
|
data-empty={!selectedDate}
|
||||||
|
className="w-full justify-start font-normal data-[empty=true]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{selectedDate
|
||||||
|
? selectedDate.toLocaleDateString(undefined, { dateStyle: 'long' })
|
||||||
|
: placeholder}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||||
|
<CalendarGridPicker
|
||||||
|
key={open ? 'open' : 'closed'}
|
||||||
|
mode="single"
|
||||||
|
selected={selectedDate}
|
||||||
|
defaultMonth={initialMonth}
|
||||||
|
startMonth={resolvedStartMonth}
|
||||||
|
endMonth={resolvedEndMonth}
|
||||||
|
disabled={disabledDates}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showLabel) {
|
||||||
|
return <div className={cn('w-full', className)}>{picker}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField id={id} label={label} className={className ?? 'mx-auto w-44'}>
|
||||||
|
{picker}
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ interface MultiSelectPopoverProps {
|
|||||||
|
|
||||||
function checkboxClass(checked: boolean) {
|
function checkboxClass(checked: boolean) {
|
||||||
return cn(
|
return cn(
|
||||||
'flex size-4 shrink-0 items-center justify-center rounded border',
|
'flex size-4 shrink-0 items-center justify-center rounded-lg border',
|
||||||
checked && 'border-primary bg-primary text-primary-foreground',
|
checked && 'border-primary bg-primary text-primary-foreground',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ export function MultiSelectPopover({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAll}
|
onClick={toggleAll}
|
||||||
aria-label={`Toggle all ${label}`}
|
aria-label={`Toggle all ${label}`}
|
||||||
className="shrink-0 cursor-pointer rounded-md p-0.5 hover:bg-muted"
|
className="shrink-0 cursor-pointer rounded-lg p-0.5 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<span className={checkboxClass(areAllSelected)}>
|
<span className={checkboxClass(areAllSelected)}>
|
||||||
{areAllSelected ? <Check className="size-3" /> : null}
|
{areAllSelected ? <Check className="size-3" /> : null}
|
||||||
@@ -128,7 +128,7 @@ export function MultiSelectPopover({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted"
|
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function SelectPopover({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted',
|
'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-muted',
|
||||||
option.disabled && 'cursor-not-allowed opacity-60',
|
option.disabled && 'cursor-not-allowed opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export { PhoneInput } from './phone-input/PhoneInput';
|
|||||||
export { SelectPopover } from './SelectPopover';
|
export { SelectPopover } from './SelectPopover';
|
||||||
export type { SelectPopoverOption } from './SelectPopover';
|
export type { SelectPopoverOption } from './SelectPopover';
|
||||||
export { PasswordField } from './PasswordField';
|
export { PasswordField } from './PasswordField';
|
||||||
|
export { DatePickerSimple } from './DatePickerSimple';
|
||||||
|
export type { DatePickerSimpleProps } from './DatePickerSimple';
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function CountryCombobox({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="h-9 shrink-0 gap-1 rounded-none border-r px-2.5 shadow-none hover:bg-transparent"
|
className="h-9 shrink-0 gap-1 rounded-lg border-r px-2.5 shadow-none hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<CountryFlag country={value} />
|
<CountryFlag country={value} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function CountryFlag({ country }: { country: CountryCode }) {
|
|||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
'flag inline-block h-3.75 w-5.5 shrink-0 rounded-[2px] bg-size-[100%_auto]',
|
'flag inline-block h-3.75 w-5.5 shrink-0 rounded-lg bg-size-[100%_auto]',
|
||||||
`flag-${country.toLowerCase()}`,
|
`flag-${country.toLowerCase()}`,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function PhoneInput({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30',
|
'flex h-9 w-full min-w-0 rounded-lg border border-input bg-transparent shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30',
|
||||||
ariaInvalid &&
|
ariaInvalid &&
|
||||||
'border-destructive focus-within:border-destructive focus-within:ring-destructive/20',
|
'border-destructive focus-within:border-destructive focus-within:ring-destructive/20',
|
||||||
disabled && 'cursor-not-allowed opacity-50',
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
@@ -80,7 +80,7 @@ export function PhoneInput({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-invalid={ariaInvalid}
|
aria-invalid={ariaInvalid}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="h-9 flex-1 rounded-none border-0 bg-transparent px-3 py-1 shadow-none focus-visible:ring-0"
|
className="h-9 flex-1 rounded-lg border-0 bg-transparent px-3 py-1 shadow-none focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
142
src/components/map/detectionLocationMap.tsx
Normal file
142
src/components/map/detectionLocationMap.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { MapPin } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
type DetectionLocationMapProps = {
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
showCoordinates?: boolean;
|
||||||
|
variant?: 'card' | 'plain';
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientDetectionLocationMapProps = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ClientDetectionLocationMap = dynamic<ClientDetectionLocationMapProps>(
|
||||||
|
async () => {
|
||||||
|
const { CircleMarker, MapContainer, Popup, TileLayer } =
|
||||||
|
await import('react-leaflet');
|
||||||
|
|
||||||
|
return function ClientDetectionLocationMapInner({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
label,
|
||||||
|
}: ClientDetectionLocationMapProps) {
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
key={`${latitude}:${longitude}`}
|
||||||
|
center={[latitude, longitude]}
|
||||||
|
zoom={17}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution="© OpenStreetMap contributors"
|
||||||
|
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<CircleMarker
|
||||||
|
center={[latitude, longitude]}
|
||||||
|
radius={10}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#dc2626',
|
||||||
|
fillColor: '#dc2626',
|
||||||
|
fillOpacity: 0.75,
|
||||||
|
weight: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>{label}</Popup>
|
||||||
|
</CircleMarker>
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="h-full w-full animate-pulse bg-muted" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DetectionLocationMap({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
label,
|
||||||
|
title = 'Detection Location',
|
||||||
|
description = 'Selected log GPS coordinates',
|
||||||
|
aspectRatio = '16 / 9',
|
||||||
|
showCoordinates = true,
|
||||||
|
variant = 'card',
|
||||||
|
className,
|
||||||
|
}: DetectionLocationMapProps) {
|
||||||
|
const mapContent =
|
||||||
|
latitude == null || longitude == null ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-sm text-muted-foreground"
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
>
|
||||||
|
Coordinates are unavailable for the selected log.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{showCoordinates ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{latitude.toFixed(6)}, {longitude.toFixed(6)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-lg border bg-muted"
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
>
|
||||||
|
<ClientDetectionLocationMap
|
||||||
|
latitude={latitude}
|
||||||
|
longitude={longitude}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'plain') {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
{title ? (
|
||||||
|
<p className="text-muted-foreground">{title}</p>
|
||||||
|
) : null}
|
||||||
|
{mapContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('overflow-hidden', className)}>
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-secondary p-2">
|
||||||
|
<MapPin className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||||
|
<CardDescription className="text-xs">{description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pt-6">{mapContent}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ export function ProtectedVideoPlayer({
|
|||||||
unavailableTitle = 'Video unavailable',
|
unavailableTitle = 'Video unavailable',
|
||||||
className,
|
className,
|
||||||
}: ProtectedVideoPlayerProps) {
|
}: ProtectedVideoPlayerProps) {
|
||||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
const { objectUrl, mediaType, isLoading, isError } =
|
||||||
|
useProtectedMediaObjectUrl(url);
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return (
|
return (
|
||||||
@@ -40,7 +41,7 @@ export function ProtectedVideoPlayer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md border bg-muted/30 text-foreground',
|
'flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-lg border bg-muted/30 text-foreground',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -68,25 +69,28 @@ export function ProtectedVideoPlayer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative overflow-hidden rounded-md border bg-black/90',
|
'group relative overflow-hidden rounded-lg border bg-black/90',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
src={objectUrl}
|
key={objectUrl}
|
||||||
className="aspect-video w-full object-cover"
|
className="aspect-video w-full bg-black object-contain"
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
preload="metadata"
|
||||||
|
>
|
||||||
|
<source src={objectUrl} type={mediaType ?? 'video/mp4'} />
|
||||||
|
</video>
|
||||||
|
|
||||||
{label ? (
|
{label ? (
|
||||||
<span className="absolute top-3 left-3 rounded bg-black/65 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
|
<span className="absolute top-3 left-3 rounded-lg bg-black/65 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{meta ? (
|
{meta ? (
|
||||||
<span className="pointer-events-none absolute top-3 right-3 max-w-[55%] truncate text-xs font-medium">
|
<span className="pointer-events-none absolute top-3 right-3 max-w-[55%] truncate text-xs font-medium text-white/90">
|
||||||
{meta}
|
{meta}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -106,7 +110,7 @@ function VideoState({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex aspect-video flex-col items-center justify-center rounded-md border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
'flex aspect-video flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,40 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
import { Moon, Sun } from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { setTheme } = useTheme();
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<Button variant="outline" size="icon" onClick={toggleTheme}>
|
||||||
<DropdownMenuTrigger asChild>
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Button variant="outline" size="icon">
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<span className="sr-only">Toggle theme</span>
|
||||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
</Button>
|
||||||
<span className="sr-only">Toggle theme</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
|
||||||
Light
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
|
||||||
Dark
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
|
||||||
System
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
UserRound,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -39,7 +40,6 @@ export function NavUser({
|
|||||||
user: {
|
user: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar: string;
|
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
@@ -76,9 +76,10 @@ export function NavUser({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarFallback>
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
<UserRound className="size-4" aria-hidden="true" />
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{user.name}</span>
|
<span className="truncate font-semibold">{user.name}</span>
|
||||||
@@ -95,9 +96,10 @@ export function NavUser({
|
|||||||
>
|
>
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarFallback>
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
<UserRound className="size-4" aria-hidden="true" />
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{user.name}</span>
|
<span className="truncate font-semibold">{user.name}</span>
|
||||||
|
|||||||
199
src/components/ui/alert-dialog.tsx
Normal file
199
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-lg bg-popover p-6 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-16 items-center justify-center rounded-lg bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ function Avatar({
|
|||||||
data-slot="avatar"
|
data-slot="avatar"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -32,7 +32,10 @@ function AvatarImage({
|
|||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
data-slot="avatar-image"
|
data-slot="avatar-image"
|
||||||
className={cn("aspect-square size-full", className)}
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -59,7 +62,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
|||||||
<span
|
<span
|
||||||
data-slot="avatar-badge"
|
data-slot="avatar-badge"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
@@ -103,7 +106,7 @@ export {
|
|||||||
Avatar,
|
Avatar,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarBadge,
|
|
||||||
AvatarGroup,
|
AvatarGroup,
|
||||||
AvatarGroupCount,
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,40 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Slot } from 'radix-ui';
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] outline-none aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3',
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-lg border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90',
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
outline:
|
outline:
|
||||||
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
ghost:
|
||||||
link: 'text-primary underline-offset-4 [a&]:hover:underline',
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
variant = 'default',
|
variant = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'> &
|
}: React.ComponentProps<"span"> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot.Root : 'span';
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -42,7 +43,7 @@ function Badge({
|
|||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants }
|
||||||
|
|||||||
@@ -1,95 +1,114 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react"
|
||||||
import Link from 'next/link';
|
import { Slot } from "radix-ui"
|
||||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
return (
|
return (
|
||||||
<ol
|
<ol
|
||||||
data-slot="breadcrumb-list"
|
data-slot="breadcrumb-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground sm:gap-2.5',
|
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground sm:gap-2.5",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
data-slot="breadcrumb-item"
|
data-slot="breadcrumb-item"
|
||||||
className={cn('inline-flex items-center gap-1.5', className)}
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type BreadcrumbLinkProps = React.ComponentProps<typeof Link>;
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
|
|
||||||
function BreadcrumbLink({ className, ...props }: BreadcrumbLinkProps) {
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Comp
|
||||||
data-slot="breadcrumb-link"
|
data-slot="breadcrumb-link"
|
||||||
className={cn('transition-colors hover:text-foreground', className)}
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="breadcrumb-page"
|
data-slot="breadcrumb-page"
|
||||||
role="link"
|
role="link"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
className={cn('font-normal text-foreground', className)}
|
className={cn("font-normal text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbSeparator({
|
function BreadcrumbSeparator({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'li'>) {
|
}: React.ComponentProps<"li">) {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
data-slot="breadcrumb-separator"
|
data-slot="breadcrumb-separator"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn('[&>svg]:size-3.5', className)}
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children ?? <ChevronRight />}
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbEllipsis({
|
function BreadcrumbEllipsis({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'>) {
|
}: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="breadcrumb-ellipsis"
|
data-slot="breadcrumb-ellipsis"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn('flex size-9 items-center justify-center', className)}
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
<span className="sr-only">More</span>
|
<span className="sr-only">More</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -100,4 +119,4 @@ export {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
BreadcrumbEllipsis,
|
BreadcrumbEllipsis,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -5,29 +5,32 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
destructive:
|
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default:
|
||||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
xs: "h-6 gap-1 rounded-lg px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
sm: "h-8 gap-1 rounded-lg px-2.5 in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||||
|
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
icon: "size-9",
|
icon: "size-9",
|
||||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
"icon-xs":
|
||||||
"icon-sm": "size-8",
|
"size-6 rounded-lg in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-8 rounded-lg in-data-[slot=button-group]:rounded-lg",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
537
src/components/ui/calendar-grid-picker.tsx
Normal file
537
src/components/ui/calendar-grid-picker.tsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
|
import { useDayPicker, type MonthCaptionProps } from 'react-day-picker';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar, CalendarDayButton } from '@/components/ui/calendar';
|
||||||
|
|
||||||
|
type CalendarView = 'days' | 'months' | 'years';
|
||||||
|
|
||||||
|
const MONTH_LABELS = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PICKER_WIDTH = 'w-[280px]';
|
||||||
|
const PICKER_SHELL_CLASS =
|
||||||
|
'group/calendar p-3 text-popover-foreground [--cell-size:--spacing(8)]';
|
||||||
|
const PICKER_HEADER_CLASS =
|
||||||
|
'grid min-h-8 grid-cols-[2rem_1fr_2rem] items-center gap-1 border-b border-border pb-2';
|
||||||
|
const PICKER_NAV_BUTTON_CLASS = 'size-8 shrink-0 p-0';
|
||||||
|
const GRID_BUTTON_CLASS =
|
||||||
|
'h-9 w-full rounded-lg font-medium text-foreground hover:bg-accent/60 hover:text-accent-foreground';
|
||||||
|
const GRID_BUTTON_ACTIVE_CLASS =
|
||||||
|
'bg-muted text-foreground hover:bg-muted hover:text-foreground';
|
||||||
|
const GRID_BUTTON_DISABLED_CLASS =
|
||||||
|
'text-muted-foreground opacity-50 hover:bg-transparent hover:text-muted-foreground';
|
||||||
|
|
||||||
|
function getMonthIndex(date: Date) {
|
||||||
|
return date.getFullYear() * 12 + date.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYearOutsideRange(year: number, startMonth?: Date, endMonth?: Date) {
|
||||||
|
if (startMonth && year < startMonth.getFullYear()) return true;
|
||||||
|
if (endMonth && year > endMonth.getFullYear()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMonthOutsideRange(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
startMonth?: Date,
|
||||||
|
endMonth?: Date,
|
||||||
|
) {
|
||||||
|
const monthIndex = year * 12 + month;
|
||||||
|
|
||||||
|
if (startMonth && monthIndex < getMonthIndex(startMonth)) return true;
|
||||||
|
if (endMonth && monthIndex > getMonthIndex(endMonth)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampMonthToRange(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
startMonth?: Date,
|
||||||
|
endMonth?: Date,
|
||||||
|
) {
|
||||||
|
let nextMonth = month;
|
||||||
|
|
||||||
|
if (startMonth && year === startMonth.getFullYear()) {
|
||||||
|
nextMonth = Math.max(nextMonth, startMonth.getMonth());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endMonth && year === endMonth.getFullYear()) {
|
||||||
|
nextMonth = Math.min(nextMonth, endMonth.getMonth());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(11, Math.max(0, nextMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarGridPickerContextValue = {
|
||||||
|
view: CalendarView;
|
||||||
|
setView: (view: CalendarView) => void;
|
||||||
|
pickerYear: number;
|
||||||
|
setPickerYear: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
decadeStart: number;
|
||||||
|
setDecadeStart: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CalendarGridPickerContext =
|
||||||
|
React.createContext<CalendarGridPickerContextValue | null>(null);
|
||||||
|
|
||||||
|
function useCalendarGridPicker() {
|
||||||
|
const context = React.useContext(CalendarGridPickerContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useCalendarGridPicker must be used within CalendarGridPicker',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickerNav({
|
||||||
|
label,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onLabelClick,
|
||||||
|
disablePrevious = false,
|
||||||
|
disableNext = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onLabelClick?: () => void;
|
||||||
|
disablePrevious?: boolean;
|
||||||
|
disableNext?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={PICKER_HEADER_CLASS}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-start')}
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={disablePrevious}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
{onLabelClick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg px-2 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={onLabelClick}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 text-center text-sm font-semibold text-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-end')}
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={disableNext}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MonthGrid({
|
||||||
|
year,
|
||||||
|
activeMonth,
|
||||||
|
onSelect,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
}: {
|
||||||
|
year: number;
|
||||||
|
activeMonth: number;
|
||||||
|
onSelect: (month: number) => void;
|
||||||
|
startMonth?: Date;
|
||||||
|
endMonth?: Date;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-2 gap-y-1.5 pt-2">
|
||||||
|
{MONTH_LABELS.map((label, month) => {
|
||||||
|
const isDisabled = isMonthOutsideRange(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
GRID_BUTTON_CLASS,
|
||||||
|
isDisabled && GRID_BUTTON_DISABLED_CLASS,
|
||||||
|
activeMonth === month && !isDisabled && GRID_BUTTON_ACTIVE_CLASS,
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(month)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YearGrid({
|
||||||
|
decadeStart,
|
||||||
|
activeYear,
|
||||||
|
onSelect,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
}: {
|
||||||
|
decadeStart: number;
|
||||||
|
activeYear: number;
|
||||||
|
onSelect: (year: number) => void;
|
||||||
|
startMonth?: Date;
|
||||||
|
endMonth?: Date;
|
||||||
|
}) {
|
||||||
|
const years = React.useMemo(
|
||||||
|
() => Array.from({ length: 10 }, (_, index) => decadeStart + index),
|
||||||
|
[decadeStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 pt-2">
|
||||||
|
{years.map((year) => {
|
||||||
|
const isDisabled = isYearOutsideRange(year, startMonth, endMonth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={year}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
GRID_BUTTON_CLASS,
|
||||||
|
isDisabled && GRID_BUTTON_DISABLED_CLASS,
|
||||||
|
activeYear === year && !isDisabled && GRID_BUTTON_ACTIVE_CLASS,
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(year)}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarMonthCaption({
|
||||||
|
calendarMonth,
|
||||||
|
className,
|
||||||
|
children: _children,
|
||||||
|
displayIndex: _displayIndex,
|
||||||
|
...props
|
||||||
|
}: MonthCaptionProps) {
|
||||||
|
const { goToMonth, previousMonth, nextMonth } = useDayPicker();
|
||||||
|
const { setView, setPickerYear, setDecadeStart } = useCalendarGridPicker();
|
||||||
|
const date = calendarMonth.date;
|
||||||
|
const monthName = date.toLocaleString('default', { month: 'long' });
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(PICKER_HEADER_CLASS, className)} {...props}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-start')}
|
||||||
|
disabled={!previousMonth}
|
||||||
|
aria-label="Previous month"
|
||||||
|
onClick={() => previousMonth && goToMonth(previousMonth)}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-1 text-sm font-semibold">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg px-1 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => {
|
||||||
|
setPickerYear(year);
|
||||||
|
setView('months');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{monthName}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg px-1 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => {
|
||||||
|
setDecadeStart(Math.floor(year / 10) * 10);
|
||||||
|
setPickerYear(year);
|
||||||
|
setView('years');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-end')}
|
||||||
|
disabled={!nextMonth}
|
||||||
|
aria-label="Next month"
|
||||||
|
onClick={() => nextMonth && goToMonth(nextMonth)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarGridPickerPanel({
|
||||||
|
month,
|
||||||
|
onMonthChange,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
}: {
|
||||||
|
month: Date;
|
||||||
|
onMonthChange: (month: Date) => void;
|
||||||
|
startMonth?: Date;
|
||||||
|
endMonth?: Date;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
pickerYear,
|
||||||
|
setPickerYear,
|
||||||
|
decadeStart,
|
||||||
|
setDecadeStart,
|
||||||
|
} = useCalendarGridPicker();
|
||||||
|
|
||||||
|
const minYear = startMonth?.getFullYear();
|
||||||
|
const maxYear = endMonth?.getFullYear();
|
||||||
|
const activeMonth = clampMonthToRange(
|
||||||
|
pickerYear,
|
||||||
|
month.getMonth(),
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (view === 'months') {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<PickerNav
|
||||||
|
label={String(pickerYear)}
|
||||||
|
onPrevious={() => setPickerYear((year) => year - 1)}
|
||||||
|
onNext={() => setPickerYear((year) => year + 1)}
|
||||||
|
onLabelClick={() => {
|
||||||
|
setDecadeStart(Math.floor(pickerYear / 10) * 10);
|
||||||
|
setView('years');
|
||||||
|
}}
|
||||||
|
disablePrevious={minYear !== undefined && pickerYear <= minYear}
|
||||||
|
disableNext={maxYear !== undefined && pickerYear >= maxYear}
|
||||||
|
/>
|
||||||
|
<MonthGrid
|
||||||
|
year={pickerYear}
|
||||||
|
activeMonth={activeMonth}
|
||||||
|
startMonth={startMonth}
|
||||||
|
endMonth={endMonth}
|
||||||
|
onSelect={(nextMonth) => {
|
||||||
|
onMonthChange(new Date(pickerYear, nextMonth, 1));
|
||||||
|
setView('days');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'years') {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<PickerNav
|
||||||
|
label={`${decadeStart} - ${decadeStart + 9}`}
|
||||||
|
onPrevious={() => setDecadeStart((start) => start - 10)}
|
||||||
|
onNext={() => setDecadeStart((start) => start + 10)}
|
||||||
|
disablePrevious={minYear !== undefined && decadeStart <= minYear}
|
||||||
|
disableNext={maxYear !== undefined && decadeStart + 10 > maxYear}
|
||||||
|
/>
|
||||||
|
<YearGrid
|
||||||
|
decadeStart={decadeStart}
|
||||||
|
activeYear={pickerYear}
|
||||||
|
startMonth={startMonth}
|
||||||
|
endMonth={endMonth}
|
||||||
|
onSelect={(year) => {
|
||||||
|
const nextMonth = clampMonthToRange(
|
||||||
|
year,
|
||||||
|
activeMonth,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
);
|
||||||
|
|
||||||
|
setPickerYear(year);
|
||||||
|
onMonthChange(new Date(year, nextMonth, 1));
|
||||||
|
setView('months');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalendarGridPickerProps = Omit<
|
||||||
|
Extract<React.ComponentProps<typeof Calendar>, { mode: 'single' }>,
|
||||||
|
'captionLayout' | 'navLayout' | 'hideNavigation'
|
||||||
|
> & {
|
||||||
|
onViewChange?: (view: CalendarView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CalendarGridPicker({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
components,
|
||||||
|
formatters,
|
||||||
|
month,
|
||||||
|
defaultMonth,
|
||||||
|
onMonthChange,
|
||||||
|
onViewChange,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
...props
|
||||||
|
}: CalendarGridPickerProps) {
|
||||||
|
const initialMonth = month ?? defaultMonth ?? new Date();
|
||||||
|
const [view, setView] = React.useState<CalendarView>('days');
|
||||||
|
const [pickerYear, setPickerYear] = React.useState(
|
||||||
|
initialMonth.getFullYear(),
|
||||||
|
);
|
||||||
|
const [decadeStart, setDecadeStart] = React.useState(
|
||||||
|
Math.floor(initialMonth.getFullYear() / 10) * 10,
|
||||||
|
);
|
||||||
|
const [internalMonth, setInternalMonth] = React.useState(initialMonth);
|
||||||
|
|
||||||
|
const currentMonth = month ?? internalMonth;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (month) {
|
||||||
|
setPickerYear(month.getFullYear());
|
||||||
|
setDecadeStart(Math.floor(month.getFullYear() / 10) * 10);
|
||||||
|
}
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
const handleViewChange = React.useCallback(
|
||||||
|
(nextView: CalendarView) => {
|
||||||
|
setView(nextView);
|
||||||
|
onViewChange?.(nextView);
|
||||||
|
},
|
||||||
|
[onViewChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMonthChange = React.useCallback(
|
||||||
|
(nextMonth: Date) => {
|
||||||
|
if (!month) setInternalMonth(nextMonth);
|
||||||
|
setPickerYear(nextMonth.getFullYear());
|
||||||
|
setDecadeStart(Math.floor(nextMonth.getFullYear() / 10) * 10);
|
||||||
|
onMonthChange?.(nextMonth);
|
||||||
|
},
|
||||||
|
[month, onMonthChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
view,
|
||||||
|
setView: handleViewChange,
|
||||||
|
pickerYear,
|
||||||
|
setPickerYear,
|
||||||
|
decadeStart,
|
||||||
|
setDecadeStart,
|
||||||
|
}),
|
||||||
|
[view, handleViewChange, pickerYear, decadeStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (view !== 'days') {
|
||||||
|
return (
|
||||||
|
<CalendarGridPickerContext.Provider value={contextValue}>
|
||||||
|
<div className={cn(PICKER_SHELL_CLASS, PICKER_WIDTH, className)}>
|
||||||
|
<CalendarGridPickerPanel
|
||||||
|
month={currentMonth}
|
||||||
|
onMonthChange={handleMonthChange}
|
||||||
|
startMonth={startMonth}
|
||||||
|
endMonth={endMonth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CalendarGridPickerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CalendarGridPickerContext.Provider value={contextValue}>
|
||||||
|
<Calendar
|
||||||
|
hideNavigation
|
||||||
|
className={cn(PICKER_SHELL_CLASS, PICKER_WIDTH, className)}
|
||||||
|
month={currentMonth}
|
||||||
|
onMonthChange={handleMonthChange}
|
||||||
|
startMonth={startMonth}
|
||||||
|
endMonth={endMonth}
|
||||||
|
formatters={{
|
||||||
|
formatWeekdayName: (date) =>
|
||||||
|
date.toLocaleString('en-US', { weekday: 'short' }).slice(0, 2),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
month: 'flex w-full flex-col gap-2',
|
||||||
|
month_caption: 'p-0',
|
||||||
|
weekdays: 'flex w-full',
|
||||||
|
weekday:
|
||||||
|
'flex-1 text-center text-[0.8rem] font-medium text-muted-foreground select-none',
|
||||||
|
week: 'mt-1 flex w-full',
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
MonthCaption: CalendarMonthCaption,
|
||||||
|
DayButton: (dayButtonProps) => (
|
||||||
|
<CalendarDayButton
|
||||||
|
{...dayButtonProps}
|
||||||
|
className={cn(
|
||||||
|
dayButtonProps.className,
|
||||||
|
'data-[selected-single=true]:rounded-full data-[selected-single=true]:bg-muted data-[selected-single=true]:text-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...(props as Extract<
|
||||||
|
React.ComponentProps<typeof Calendar>,
|
||||||
|
{ mode: 'single' }
|
||||||
|
>)}
|
||||||
|
/>
|
||||||
|
</CalendarGridPickerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CalendarGridPicker, type CalendarView };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user