28 Commits

Author SHA1 Message Date
224361f144 fix(ui): prevent card borders from being clipped 2026-07-24 15:17:04 +05:30
77b6755d05 refactor(ui): use icon fallback for sidebar user avatar 2026-07-23 10:16:34 +05:30
2f25fd49be fix(ticket): improve ticket detail page scrolling layout 2026-07-22 19:19:50 +05:30
3891500cf2 feat(upload): open upload details dialog from table rows 2026-07-22 19:12:52 +05:30
062e89fcba refactor(ui): update branding assets and clean up repair proof UI 2026-07-22 18:47:00 +05:30
725c256791 refactor(api): simplify backend proxy to api and biz paths 2026-07-22 17:07:05 +05:30
5d3dac0b21 refactor(api): add same-origin backend proxy for auth cookies 2026-07-22 16:18:17 +05:30
e077467bce refactor(ticket): remove web repair upload flow 2026-07-22 15:41:38 +05:30
e94da54aa9 feat(ticket): add issue-by-issue repair review page 2026-07-22 14:46:34 +05:30
76c87811d8 feat(tickets): add repair proof status filter for class issues 2026-07-21 19:24:40 +05:30
830883c6b7 fix(auth): prevent logout on transient API failures 2026-07-13 19:29:04 +05:30
68829dc72b feat(tickets): add detection discard workflow 2026-07-13 19:27:54 +05:30
9d7f956c3b feat(ticket): show assigned contractor details on in-progress tickets 2026-07-10 18:27:26 +05:30
0026824f84 Assign to Contractor // here in due date also change the year to 20 2026-07-10 16:17:53 +05:30
a18eb64dac feat(ticket): enable circular detection preview navigation 2026-07-10 16:12:30 +05:30
6c71d8f8a7 fix(ticket): widen extension due date picker range 2026-07-10 15:56:00 +05:30
0e2172366b refactor(ui): replace theme dropdown with toggle button 2026-07-10 13:38:17 +05:30
56180f8d18 style(ui): use pointer cursor for dropdown menu items 2026-07-10 13:25:28 +05:30
5269b29aac feat(ticket): use grid picker for extension due date 2026-07-10 12:24:44 +05:30
9fe7109faf style: widen plan and role sheet panels 2026-07-10 11:31:36 +05:30
9720546455 refactor(ticket): update ticket detail skeleton loaders 2026-07-10 10:42:40 +05:30
8727963c54 style: refine shared UI primitives and management flows 2026-07-09 23:06:25 +05:30
0e8108b875 feat: modify table design 2026-07-09 17:14:37 +05:30
87dde1c8d1 feat: sync codebase with latest radix pattern 2026-07-09 17:07:11 +05:30
a376558b2f refactor(layout): improve module header and sidebar toggle UX 2026-07-09 11:54:16 +05:30
4fe93460d9 feat(ui): use grid calendar picker for modern ui look 2026-07-09 10:31:36 +05:30
cbfb6e040b refactor(ticket): rename View Analysis button to View Video 2026-07-08 17:51:42 +05:30
7a82e17bbb feat(results): add live annotation overlay on raw video playback 2026-07-08 17:24:20 +05:30
143 changed files with 8499 additions and 3079 deletions

View File

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

View File

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

View File

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

3320
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,8 @@
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"recharts": "2.15.4", "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",

5
public/color_logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

5
public/logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -33,7 +33,7 @@ function ResetPasswordContent() {
</header> </header>
{!hasToken ? ( {!hasToken ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-destructive"> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-destructive">
Password reset token is missing. Password reset token is missing.
</div> </div>
) : ( ) : (

View File

@@ -0,0 +1,27 @@
'use client';
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
import { ModeToggle } from '@/components/mode-toogle';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
export function ModuleShellHeader() {
const { isMobile } = useSidebar();
return (
<div className="sticky top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-backdrop-filter:bg-background/80">
<div className="flex min-w-0 items-center gap-3">
{isMobile ? (
<>
<SidebarTrigger className="shrink-0 bg-background/80 text-muted-foreground shadow-xs backdrop-blur-sm" />
<Separator orientation="vertical" className="h-4 shrink-0" />
</>
) : null}
<div className="min-w-0">
<BreadcrumbBasic />
</div>
</div>
<ModeToggle />
</div>
);
}

View File

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

View File

@@ -1,10 +1,8 @@
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebar } from '@/components/app-sidebar';
import { ModeToggle } from '@/components/mode-toogle'; import { SidebarProvider } from '@/components/ui/sidebar';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
import { AuthGuard } from '@/guards'; import { AuthGuard } from '@/guards';
import React from 'react'; import React from 'react';
import { ModuleShellHeader } from './_components/module-shell-header';
const ModulesLayout = ({ const ModulesLayout = ({
children, children,
@@ -15,16 +13,11 @@ const ModulesLayout = ({
<AuthGuard> <AuthGuard>
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden"> <main className="flex flex-1 h-screen w-full flex-col overflow-hidden">
<div className="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>

View File

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

View File

@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
import { Package as PackageIcon, Plus } from 'lucide-react'; import { Package as PackageIcon, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Package } from '@/types'; import type { Package } from '@/types';
@@ -20,6 +30,7 @@ import {
export default function PackagePage() { export default function PackagePage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
const { skip, setSkip, limit, setLimit } = usePackageFilters(); const { skip, setSkip, limit, setLimit } = usePackageFilters();
const packagesQuery = usePackagesQuery({ skip, limit }); const packagesQuery = usePackagesQuery({ skip, limit });
const projectsQuery = useProjectOptionsQuery(); const projectsQuery = useProjectOptionsQuery();
@@ -67,14 +78,25 @@ export default function PackagePage() {
const deletePackage = useCallback( const deletePackage = useCallback(
(pkg: Package) => { (pkg: Package) => {
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) { setPackageToDelete(pkg);
return;
}
deletePackageMutation.mutate(pkg);
}, },
[deletePackageMutation], [],
); );
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
if (!open) {
setPackageToDelete(null);
}
}, []);
const confirmDeletePackage = useCallback(() => {
if (!packageToDelete) return;
deletePackageMutation.mutate(packageToDelete, {
onSettled: () => setPackageToDelete(null),
});
}, [deletePackageMutation, packageToDelete]);
const projects = projectsQuery.data?.items ?? []; const projects = projectsQuery.data?.items ?? [];
const packages = packagesQuery.data?.items ?? []; const packages = packagesQuery.data?.items ?? [];
const total = packagesQuery.data?.totalItems ?? 0; const total = packagesQuery.data?.totalItems ?? 0;
@@ -122,6 +144,36 @@ export default function PackagePage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} isSaving={isSaving}
/> />
<AlertDialog
open={Boolean(packageToDelete)}
onOpenChange={handleDeleteDialogOpenChange}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete package?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete{' '}
<span className="font-medium text-foreground">
{packageToDelete?.name}
</span>
. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePackageMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deletePackageMutation.isPending}
onClick={confirmDeletePackage}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
); );
} }

View File

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

View File

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

View File

@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
import { Layers, Plus } from 'lucide-react'; import { Layers, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Project } from '@/types'; import type { Project } from '@/types';
@@ -19,6 +29,7 @@ import {
export default function ProjectPage() { export default function ProjectPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const { skip, setSkip, limit, setLimit } = useProjectFilters(); const { skip, setSkip, limit, setLimit } = useProjectFilters();
const projectsQuery = useProjectsQuery({ skip, limit }); const projectsQuery = useProjectsQuery({ skip, limit });
const deleteProjectMutation = useDeleteProjectMutation(); const deleteProjectMutation = useDeleteProjectMutation();
@@ -63,16 +74,25 @@ export default function ProjectPage() {
const deleteProject = useCallback( const deleteProject = useCallback(
(project: Project) => { (project: Project) => {
if ( setProjectToDelete(project);
!confirm(`Are you sure you want to delete project "${project.name}"?`)
) {
return;
}
deleteProjectMutation.mutate(project);
}, },
[deleteProjectMutation], [],
); );
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
if (!open) {
setProjectToDelete(null);
}
}, []);
const confirmDeleteProject = useCallback(() => {
if (!projectToDelete) return;
deleteProjectMutation.mutate(projectToDelete, {
onSettled: () => setProjectToDelete(null),
});
}, [deleteProjectMutation, projectToDelete]);
const columns = useProjectColumns(); const columns = useProjectColumns();
const projects = projectsQuery.data?.items ?? []; const projects = projectsQuery.data?.items ?? [];
const total = projectsQuery.data?.totalItems ?? 0; const total = projectsQuery.data?.totalItems ?? 0;
@@ -117,6 +137,36 @@ export default function ProjectPage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} isSaving={isSaving}
/> />
<AlertDialog
open={Boolean(projectToDelete)}
onOpenChange={handleDeleteDialogOpenChange}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete{' '}
<span className="font-medium text-foreground">
{projectToDelete?.name}
</span>
. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteProjectMutation.isPending}
onClick={confirmDeleteProject}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
); );
} }

View File

@@ -5,7 +5,7 @@ import { Skeleton } from '@/components/ui/skeleton';
function StatSkeleton() { function StatSkeleton() {
return ( return (
<div className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center"> <div className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center">
<Skeleton className="mb-2 size-9 rounded-md" /> <Skeleton className="mb-2 size-9 rounded-lg" />
<Skeleton className="h-6 w-12" /> <Skeleton className="h-6 w-12" />
<Skeleton className="mt-2 h-3 w-20" /> <Skeleton className="mt-2 h-3 w-20" />
</div> </div>
@@ -14,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>

View File

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

View File

@@ -2,6 +2,10 @@ export const videoKeys = {
all: ['videos'] as const, all: ['videos'] as const,
results: () => [...videoKeys.all, 'results'] as const, results: () => [...videoKeys.all, 'results'] as const,
result: (videoId: string) => [...videoKeys.results(), videoId] as const, result: (videoId: string) => [...videoKeys.results(), videoId] as const,
annotationFrames: (
videoId: string,
params: { start_time_ms: number; end_time_ms: number },
) => [...videoKeys.result(videoId), 'annotation-frames', params] as const,
annotatedVideos: () => [...videoKeys.all, 'annotated'] as const, annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
annotatedVideo: (url: string) => annotatedVideo: (url: string) =>
[...videoKeys.annotatedVideos(), url] as const, [...videoKeys.annotatedVideos(), url] as const,

View File

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

View File

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

View File

@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
import { Milestone, Plus } from 'lucide-react'; import { Milestone, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Chainage } from '@/types'; import type { Chainage } from '@/types';
@@ -21,6 +31,7 @@ import {
export default function SegmentPage() { export default function SegmentPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
const { skip, setSkip, limit, setLimit } = useSegmentFilters(); const { skip, setSkip, limit, setLimit } = useSegmentFilters();
const segmentsQuery = useSegmentsQuery({ skip, limit }); const segmentsQuery = useSegmentsQuery({ skip, limit });
const projectsQuery = useProjectOptionsQuery(); const projectsQuery = useProjectOptionsQuery();
@@ -75,18 +86,25 @@ export default function SegmentPage() {
const deleteSegment = useCallback( const deleteSegment = useCallback(
(segment: Chainage) => { (segment: Chainage) => {
if ( setSegmentToDelete(segment);
!confirm(
`Are you sure you want to delete segment "${segment.segment_name}"?`,
)
) {
return;
}
deleteSegmentMutation.mutate(segment);
}, },
[deleteSegmentMutation], [],
); );
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
if (!open) {
setSegmentToDelete(null);
}
}, []);
const confirmDeleteSegment = useCallback(() => {
if (!segmentToDelete) return;
deleteSegmentMutation.mutate(segmentToDelete, {
onSettled: () => setSegmentToDelete(null),
});
}, [deleteSegmentMutation, segmentToDelete]);
const projects = projectsQuery.data?.items ?? []; const projects = projectsQuery.data?.items ?? [];
const allPackages = allPackagesQuery.data?.items ?? []; const allPackages = allPackagesQuery.data?.items ?? [];
const segments = segmentsQuery.data?.items ?? []; const segments = segmentsQuery.data?.items ?? [];
@@ -139,6 +157,36 @@ export default function SegmentPage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} isSaving={isSaving}
/> />
<AlertDialog
open={Boolean(segmentToDelete)}
onOpenChange={handleDeleteDialogOpenChange}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete segment?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete{' '}
<span className="font-medium text-foreground">
{segmentToDelete?.segment_name}
</span>
. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteSegmentMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteSegmentMutation.isPending}
onClick={confirmDeleteSegment}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
); );
} }

View File

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

View File

@@ -0,0 +1,180 @@
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
import { cn } from '@/lib/utils';
import type { DetectionResultItem } from '@/types';
import { formatDate } from '@/utils/date';
import { RepairProofGallery } from '../../../components/repair/RepairProofGallery';
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
not_submitted: 'Awaiting Repair',
pending: 'Under Review',
approved: 'Approved',
rejected: 'Rejected',
};
const REVIEW_STATUS_CLASS: Record<IssueReviewStatus, string> = {
not_submitted: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
pending: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
approved: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
rejected: 'bg-destructive/10 text-destructive',
};
function MetadataItem({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="min-w-0 space-y-1 px-4 first:pl-0 last:pr-0">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="truncate text-sm font-semibold text-foreground">
{value}
</div>
</div>
);
}
interface IssueEvidencePanelProps {
detection: DetectionResultItem;
issueNumber: number;
videoWidth: number;
videoHeight: number;
reviewStatus: IssueReviewStatus;
}
export function IssueEvidencePanel({
detection,
issueNumber,
videoWidth,
videoHeight,
reviewStatus,
}: IssueEvidencePanelProps) {
const repairProof = detection.repair_proof;
const displayName = detection.detection.display_name;
const confidence = Math.round(detection.detection.confidence * 100);
return (
<div className="min-w-0 space-y-4">
<Card>
<CardHeader className="border-b">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">
{displayName} #{issueNumber}
</CardTitle>
<Badge
variant="secondary"
className={cn(REVIEW_STATUS_CLASS[reviewStatus])}
>
{REVIEW_STATUS_LABEL[reviewStatus]}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 divide-x sm:grid-cols-4">
<MetadataItem
label="Video Timestamp"
value={formatVideoTimestamp(detection.frame.timestamp_seconds)}
/>
<MetadataItem label="Frame" value={detection.frame.number} />
<MetadataItem label="Confidence" value={`${confidence}%`} />
<MetadataItem label="Issue ID" value={detection.detection.id} />
</div>
</CardContent>
</Card>
<div className="grid items-start gap-4 2xl:grid-cols-2">
<section className="min-w-0 space-y-2">
<p className="flex items-center gap-2 text-sm font-semibold">
<ImageIcon className="size-4 text-primary" />
Detection Image (Before)
</p>
<AnnotatedDetectionImage
detection={detection}
videoWidth={videoWidth}
videoHeight={videoHeight}
aspectRatio="16 / 9"
enableLightbox
/>
</section>
<section className="min-w-0 space-y-2">
<p className="flex items-center gap-2 text-sm font-semibold">
<MapPin className="size-4 text-primary" />
Detection Location
</p>
<DetectionLocationMap
latitude={detection.location.latitude}
longitude={detection.location.longitude}
label={`${displayName} #${issueNumber} - Frame ${detection.frame.number}`}
title=""
variant="plain"
aspectRatio="16 / 9"
showCoordinates={false}
/>
</section>
</div>
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm font-semibold">
<CalendarCheck2 className="size-4 text-primary" />
Repair Proof (After Repair)
</p>
{repairProof?.submitted ? (
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Smartphone />
Uploaded from App
</Badge>
) : null}
</div>
<div className="space-y-5">
{!repairProof?.submitted ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center">
<p className="text-sm font-medium">
Repair proof is not submitted yet.
</p>
<p className="mt-1 text-xs text-muted-foreground">
This issue cannot be reviewed until repair evidence is
available.
</p>
</div>
) : (
<>
{repairProof.submitted_at ? (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
Uploaded from the field app on{' '}
{formatDate(repairProof.submitted_at)}
</p>
</div>
) : null}
<RepairProofGallery
imageUrls={repairProof.proof_image_urls}
maxVisibleImages={4}
/>
<div className="space-y-1.5 rounded-lg border bg-card p-4 shadow-xs">
<p className="text-xs font-semibold text-foreground">
Repair Notes
</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
{repairProof.notes?.trim() ||
'No repair notes were provided.'}
</p>
</div>
</>
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useEffect, useState } from 'react';
import {
Check,
CheckCircle2,
ClipboardCheck,
Info,
Loader2,
X,
XCircle,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type {
DetectionResultItem,
ReviewDetectionRepairProofPayload,
} from '@/types';
import { formatDate } from '@/utils/date';
import type { IssueReviewStatus } from './reviewTypes';
type ReviewDecision = ReviewDetectionRepairProofPayload['action'];
interface IssueReviewActionProps {
detection: DetectionResultItem;
issueLabel: string;
status: IssueReviewStatus;
isPending: boolean;
onSave: (payload: ReviewDetectionRepairProofPayload) => Promise<void>;
}
export function IssueReviewAction({
detection,
issueLabel,
status,
isPending,
onSave,
}: IssueReviewActionProps) {
const [comment, setComment] = useState('');
const [decision, setDecision] = useState<ReviewDecision>();
useEffect(() => {
setComment('');
setDecision(undefined);
}, [detection.detection.id]);
if (status === 'not_submitted') {
return (
<Card>
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="size-5 text-primary" />
Review & Action
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-center">
<Info className="mx-auto size-6 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">Repair proof required</p>
<p className="mt-1 text-xs text-muted-foreground">
{issueLabel} is not ready for review.
</p>
</div>
</CardContent>
</Card>
);
}
if (status === 'approved' || status === 'rejected') {
const isApproved = status === 'approved';
const repairProof = detection.repair_proof;
return (
<Card>
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="size-5 text-primary" />
Review Result
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div
className={cn(
'rounded-lg border px-4 py-5 text-center',
isApproved
? 'border-emerald-500/20 bg-emerald-500/5'
: 'border-destructive/20 bg-destructive/5',
)}
>
{isApproved ? (
<CheckCircle2 className="mx-auto size-8 text-emerald-600" />
) : (
<XCircle className="mx-auto size-8 text-destructive" />
)}
<Badge
className={cn(
'mt-3',
isApproved
? 'bg-emerald-500/10 text-emerald-600'
: 'bg-destructive/10 text-destructive',
)}
>
{isApproved ? 'Approved' : 'Rejected'}
</Badge>
{repairProof?.reviewed_by_name || repairProof?.reviewed_at ? (
<p className="mt-2 text-xs text-muted-foreground">
{repairProof.reviewed_by_name
? `By ${repairProof.reviewed_by_name}`
: 'Reviewed'}
{repairProof.reviewed_at
? ` on ${formatDate(repairProof.reviewed_at)}`
: ''}
</p>
) : null}
</div>
{repairProof?.review_comment ? (
<div className="space-y-1.5 rounded-lg bg-muted/30 p-3">
<p className="text-xs font-semibold">Review Comment</p>
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
{repairProof.review_comment}
</p>
</div>
) : null}
</CardContent>
</Card>
);
}
const submitDecision = async (nextDecision: ReviewDecision) => {
setDecision(nextDecision);
try {
await onSave({
action: nextDecision,
comment: comment.trim() || undefined,
});
} catch {
// The mutation owns user-facing error feedback.
}
};
return (
<Card>
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="size-5 text-primary" />
Review & Action
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<Label htmlFor="review-comment">Comment (Optional)</Label>
<Textarea
id="review-comment"
value={comment}
maxLength={4000}
rows={5}
disabled={isPending}
onChange={(event) => setComment(event.target.value)}
placeholder="Enter your review comments..."
/>
<p className="text-right text-xs text-muted-foreground">
{comment.length} / 4000
</p>
</div>
<div className="space-y-2">
<Label>Decision</Label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => void submitDecision('approve')}
className={cn(
decision === 'approve' &&
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
)}
>
{isPending && decision === 'approve' ? (
<Loader2 className="animate-spin" />
) : (
<Check />
)}
Approve
</Button>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => void submitDecision('reject')}
className={cn(
decision === 'reject' &&
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
)}
>
{isPending && decision === 'reject' ? (
<Loader2 className="animate-spin" />
) : (
<X />
)}
Reject
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,113 @@
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { ProofStatusFilter } from './reviewTypes';
const PROOF_STATUS_FILTERS: Array<{
value: ProofStatusFilter;
label: string;
}> = [
{ value: 'all', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' },
];
interface IssueReviewHeaderProps {
ticketLabel: string;
defectLabel: string;
currentIndex: number;
total: number;
proofStatus: ProofStatusFilter;
onBack: () => void;
onPrevious: () => void;
onNext: () => void;
onProofStatusChange: (status: ProofStatusFilter) => void;
}
export function IssueReviewHeader({
ticketLabel,
defectLabel,
currentIndex,
total,
proofStatus,
onBack,
onPrevious,
onNext,
onProofStatusChange,
}: IssueReviewHeaderProps) {
return (
<header className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-start gap-3">
<Button
type="button"
variant="secondary"
size="icon"
onClick={onBack}
aria-label="Back to ticket"
className="shrink-0"
>
<ArrowLeft />
</Button>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-2xl font-semibold tracking-tight">
{defectLabel} Review
</h1>
<Badge variant="secondary">{ticketLabel}</Badge>
</div>
<div
className="flex max-w-full flex-nowrap items-center gap-1.5 overflow-x-auto pt-1 pb-1"
aria-label="Filter issues by repair proof status"
>
{PROOF_STATUS_FILTERS.map((filter) => {
const isActive = proofStatus === filter.value;
return (
<Button
key={filter.value}
type="button"
size="sm"
variant={isActive ? 'default' : 'outline'}
aria-pressed={isActive}
onClick={() => onProofStatusChange(filter.value)}
className="h-7 shrink-0 rounded-full px-3 text-xs"
>
{filter.label}
</Button>
);
})}
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end lg:self-auto">
<Button
type="button"
variant="outline"
size="icon"
disabled={total === 0 || currentIndex <= 0}
onClick={onPrevious}
aria-label="Previous issue"
>
<ChevronLeft />
</Button>
<div className="min-w-16 text-center text-sm font-semibold">
{total === 0 ? '0 of 0' : `${currentIndex + 1} of ${total}`}
</div>
<Button
type="button"
variant="outline"
size="icon"
disabled={total === 0 || currentIndex >= total - 1}
onClick={onNext}
aria-label="Next issue"
>
<ChevronRight />
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useEffect, useRef } from 'react';
import {
CheckCircle2,
Clock3,
Hourglass,
ListChecks,
Wrench,
XCircle,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { DetectionResultItem } from '@/types';
import {
formatVideoTimestamp,
getIssueId,
getIssueReviewStatus,
type IssueReviewStatus,
} from './reviewTypes';
const STATUS_CONFIG: Record<
IssueReviewStatus,
{
label: string;
icon: typeof Hourglass;
className: string;
}
> = {
not_submitted: {
label: 'Awaiting Repair',
icon: Wrench,
className: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
},
pending: {
label: 'Under Review',
icon: Hourglass,
className: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
},
approved: {
label: 'Approved',
icon: CheckCircle2,
className: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
},
rejected: {
label: 'Rejected',
icon: XCircle,
className: 'bg-destructive/10 text-destructive',
},
};
interface IssueReviewQueueProps {
detections: DetectionResultItem[];
totalCount: number;
selectedIssueId?: string;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onSelect: (issueId: string) => void;
onLoadMore: () => void;
}
export function IssueReviewQueue({
detections,
totalCount,
selectedIssueId,
hasNextPage,
isFetchingNextPage,
onSelect,
onLoadMore,
}: IssueReviewQueueProps) {
const loadMoreRef = useRef<HTMLDivElement>(null);
const defectLabel = detections[0]?.detection.display_name ?? 'Detected';
useEffect(() => {
const target = loadMoreRef.current;
if (!target || !hasNextPage || isFetchingNextPage) return;
const root = target.closest<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onLoadMore();
},
{ root, rootMargin: '160px 0px' },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, onLoadMore]);
return (
<Card className="overflow-hidden xl:h-full xl:min-h-0">
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base">
<ListChecks className="size-5 text-primary" />
{defectLabel} Issues
<Badge variant="secondary" className="ml-auto rounded-full">
{totalCount}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-2">
<ScrollArea className="h-112 pr-2 xl:h-full">
<div className="space-y-2 p-1">
{detections.map((detection, index) => {
const issueId = getIssueId(detection);
const status = getIssueReviewStatus(detection);
const config = STATUS_CONFIG[status];
const StatusIcon = config.icon;
const isSelected = issueId === selectedIssueId;
return (
<button
key={detection.id}
type="button"
onClick={() => onSelect(issueId)}
aria-current={isSelected ? 'true' : undefined}
className={cn(
'w-full rounded-lg border p-3 text-left transition-colors',
'hover:border-primary/40 hover:bg-muted/50',
isSelected
? 'border-primary bg-primary/6 shadow-sm'
: 'border-border bg-card',
)}
>
<div className="min-w-0 space-y-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
{defectLabel} #{index + 1}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{defectLabel} #{index + 1}
</TooltipContent>
</Tooltip>
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<Clock3 className="size-3.5" />
{formatVideoTimestamp(
detection.frame.timestamp_seconds,
)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="secondary"
className={cn('shrink-0 px-1.5', config.className)}
>
<StatusIcon />
{config.label}
</Badge>
<span aria-hidden="true">|</span>
<span>Frame {detection.frame.number}</span>
</div>
</div>
</button>
);
})}
<div ref={loadMoreRef} className="space-y-2 py-1">
{isFetchingNextPage ? (
<>
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</>
) : hasNextPage ? (
<p className="py-3 text-center text-xs text-muted-foreground">
Scroll to load more issues
</p>
) : detections.length > 0 ? (
<p className="py-3 text-center text-xs text-muted-foreground">
All {totalCount} issues loaded
</p>
) : null}
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function IssueReviewSkeleton() {
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div className="space-y-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-36" />
</div>
<Skeleton className="h-9 w-32" />
</div>
<div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)_330px]">
{[280, 600, 330].map((width) => (
<Card key={width}>
<CardHeader className="border-b">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-20 w-full" />
))}
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import type { DetectionProofStatus, DetectionResultItem } from '@/types';
export type ProofStatusFilter =
| 'all'
| Extract<DetectionProofStatus, 'pending' | 'approved' | 'rejected'>;
export type IssueReviewStatus =
| 'not_submitted'
| 'pending'
| 'approved'
| 'rejected';
export function getIssueId(detection: DetectionResultItem) {
return String(detection.detection.id);
}
export function getIssueReviewStatus(
detection: DetectionResultItem,
): IssueReviewStatus {
const repairProof = detection.repair_proof;
if (!repairProof?.submitted) return 'not_submitted';
return repairProof.review_status;
}
export function formatVideoTimestamp(totalSeconds: number) {
const seconds = Math.max(0, Math.floor(totalSeconds));
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}:${remainder.toString().padStart(2, '0')}`;
}

View File

@@ -0,0 +1,325 @@
'use client';
import { useCallback, useEffect, useMemo } from 'react';
import {
useParams,
usePathname,
useRouter,
useSearchParams,
} from 'next/navigation';
import { AlertTriangle, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { ROUTES } from '@/utils/routes';
import type { ReviewDetectionRepairProofPayload } from '@/types';
import {
useReviewDetectionRepairProofMutation,
useTicketClassReviewDetectionsQuery,
useTicketOverviewQuery,
} from '../../../hooks/useTicketQueries';
import { IssueEvidencePanel } from './components/IssueEvidencePanel';
import { IssueReviewAction } from './components/IssueReviewAction';
import { IssueReviewHeader } from './components/IssueReviewHeader';
import { IssueReviewQueue } from './components/IssueReviewQueue';
import { IssueReviewSkeleton } from './components/IssueReviewSkeleton';
import {
getIssueId,
getIssueReviewStatus,
type ProofStatusFilter,
} from './components/reviewTypes';
const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
'all',
'pending',
'approved',
'rejected',
];
export default function TicketClassReviewPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { ticketId, defectClass } = useParams() as {
ticketId: string;
defectClass: string;
};
const proofStatusParam = searchParams.get('proof_status');
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
proofStatusParam as ProofStatusFilter,
)
? (proofStatusParam as ProofStatusFilter)
: 'all';
const overviewQuery = useTicketOverviewQuery(ticketId);
const ticket = overviewQuery.data;
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
const detectionsQuery = useTicketClassReviewDetectionsQuery(
videoId,
defectClass,
proofStatus === 'all' ? 'submitted' : proofStatus,
);
const detectionResult = detectionsQuery.data?.pages[0];
const detections = useMemo(
() => detectionsQuery.data?.pages.flatMap((page) => page.items) ?? [],
[detectionsQuery.data?.pages],
);
const totalCount = detectionResult?.total ?? 0;
const selectedIssueParam = searchParams.get('issue_id') ?? undefined;
const reviewMutation = useReviewDetectionRepairProofMutation({
ticketId,
videoId,
defectClass,
});
const selectIssue = useCallback(
(issueId: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('issue_id', issueId);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
},
[pathname, router, searchParams],
);
const handleProofStatusChange = useCallback(
(status: ProofStatusFilter) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (status === 'all') {
nextParams.delete('proof_status');
} else {
nextParams.set('proof_status', status);
}
nextParams.delete('issue_id');
const query = nextParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
},
[pathname, router, searchParams],
);
useEffect(() => {
if (!detections.length) return;
const hasSelectedIssue = detections.some(
(detection) => getIssueId(detection) === selectedIssueParam,
);
if (hasSelectedIssue) return;
if (
selectedIssueParam &&
detectionsQuery.hasNextPage &&
!detectionsQuery.isFetchingNextPage
) {
void detectionsQuery.fetchNextPage();
return;
}
const firstReviewable = detections.find(
(detection) => getIssueReviewStatus(detection) === 'pending',
);
selectIssue(getIssueId(firstReviewable ?? detections[0]));
}, [detections, detectionsQuery, selectIssue, selectedIssueParam]);
const activeIndex = Math.max(
0,
detections.findIndex(
(detection) => getIssueId(detection) === selectedIssueParam,
),
);
const activeDetection = detections[activeIndex];
const activeIssueId = activeDetection
? getIssueId(activeDetection)
: undefined;
const activeReviewStatus = activeDetection
? getIssueReviewStatus(activeDetection)
: 'not_submitted';
const handleSaveReview = useCallback(
async (payload: ReviewDetectionRepairProofPayload) => {
if (!activeDetection) return;
await reviewMutation.mutateAsync({
detectionId: activeDetection.detection.id,
payload,
});
},
[activeDetection, reviewMutation],
);
const handleNext = useCallback(async () => {
if (activeIndex < detections.length - 1) {
selectIssue(getIssueId(detections[activeIndex + 1]));
return;
}
if (!detectionsQuery.hasNextPage) return;
const nextResult = await detectionsQuery.fetchNextPage();
const nextDetections =
nextResult.data?.pages.flatMap((page) => page.items) ?? [];
const nextDetection = nextDetections[activeIndex + 1];
if (nextDetection) selectIssue(getIssueId(nextDetection));
}, [activeIndex, detections, detectionsQuery, selectIssue]);
const handleLoadMore = useCallback(() => {
if (detectionsQuery.hasNextPage && !detectionsQuery.isFetchingNextPage) {
void detectionsQuery.fetchNextPage();
}
}, [detectionsQuery]);
const backToTicket = () => {
const params = new URLSearchParams({ defect_class: defectClass });
router.push(`${ROUTES.TICKET_DETAIL(ticketId)}?${params.toString()}`);
};
if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) {
return <IssueReviewSkeleton />;
}
if (overviewQuery.isError || !ticket || !videoId) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-destructive" />
<div>
<p className="font-semibold">
Unable to open the review workspace.
</p>
<p className="mt-1 text-sm text-muted-foreground">
Ticket or video information is unavailable.
</p>
</div>
<Button type="button" variant="outline" onClick={backToTicket}>
Back to Ticket
</Button>
</CardContent>
</Card>
);
}
if (detectionsQuery.isError) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-destructive" />
<div>
<p className="font-semibold">Failed to load class detections.</p>
<p className="mt-1 text-sm text-muted-foreground">
The review screen uses the detection API for its queue and
evidence.
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={backToTicket}>
Back
</Button>
<Button
type="button"
onClick={() => void detectionsQuery.refetch()}
>
Retry
</Button>
</div>
</CardContent>
</Card>
);
}
if (!activeDetection || !detectionResult) {
return (
<div className="space-y-5">
<IssueReviewHeader
ticketLabel={ticket.ticket_name || ticket.id}
defectLabel={defectClass}
currentIndex={0}
total={0}
proofStatus={proofStatus}
onBack={backToTicket}
onPrevious={() => undefined}
onNext={() => undefined}
onProofStatusChange={handleProofStatusChange}
/>
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<ClipboardCheck className="size-8 text-muted-foreground" />
<div>
<p className="font-semibold">No detections found.</p>
<p className="mt-1 text-sm text-muted-foreground">
There are no {defectClass} issues available for review.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
const defectLabel = activeDetection.detection.display_name;
return (
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<IssueReviewHeader
ticketLabel={ticket.ticket_name || ticket.id}
defectLabel={defectLabel}
currentIndex={activeIndex}
total={totalCount}
proofStatus={proofStatus}
onBack={backToTicket}
onPrevious={() =>
selectIssue(getIssueId(detections[Math.max(0, activeIndex - 1)]))
}
onNext={() => void handleNext()}
onProofStatusChange={handleProofStatusChange}
/>
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[280px_minmax(0,1fr)_330px] xl:items-stretch xl:overflow-hidden">
<IssueReviewQueue
detections={detections}
totalCount={totalCount}
selectedIssueId={activeIssueId}
hasNextPage={Boolean(detectionsQuery.hasNextPage)}
isFetchingNextPage={detectionsQuery.isFetchingNextPage}
onSelect={selectIssue}
onLoadMore={handleLoadMore}
/>
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
<IssueEvidencePanel
detection={activeDetection}
issueNumber={activeIndex + 1}
videoWidth={detectionResult.video_width}
videoHeight={detectionResult.video_height}
reviewStatus={activeReviewStatus}
/>
</div>
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
<PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW}
fallback={
<Card>
<CardContent className="py-8 text-center">
<ClipboardCheck className="mx-auto size-7 text-muted-foreground" />
<p className="mt-3 text-sm font-semibold">View-only access</p>
<p className="mt-1 text-xs text-muted-foreground">
Review permission is required to take action.
</p>
</CardContent>
</Card>
}
>
<IssueReviewAction
detection={activeDetection}
issueLabel={`${defectLabel} #${activeIndex + 1}`}
status={activeReviewStatus}
isPending={reviewMutation.isPending}
onSave={handleSaveReview}
/>
</PermissionGuard>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import { useState, type FormEvent } from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type { DetectionDiscardReason, DiscardDetectionPayload } from '@/types';
const DISCARD_REASON_OPTIONS: Array<{
value: DetectionDiscardReason;
label: string;
}> = [
{ value: 'not_a_defect', label: 'Not a defect' },
{ value: 'wrong_class', label: 'Wrong classification' },
{ value: 'duplicate', label: 'Duplicate detection' },
{ value: 'poor_image_quality', label: 'Poor image quality' },
{ value: 'incorrect_location', label: 'Incorrect location' },
{ value: 'already_repaired', label: 'Already repaired' },
{ value: 'other', label: 'Other' },
];
interface DetectionDiscardDialogProps {
open: boolean;
detectionLabel: string;
isPending: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: DiscardDetectionPayload) => Promise<void>;
}
export function DetectionDiscardDialog({
open,
detectionLabel,
isPending,
onOpenChange,
onSubmit,
}: DetectionDiscardDialogProps) {
const [reason, setReason] = useState<DetectionDiscardReason | ''>('');
const [comment, setComment] = useState('');
const resetForm = () => {
setReason('');
setComment('');
};
const handleOpenChange = (nextOpen: boolean) => {
if (isPending) return;
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!reason || isPending) return;
try {
await onSubmit({
reason,
comment: comment.trim() || undefined,
});
resetForm();
} catch {
// The mutation displays the request error and keeps the dialog open.
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={!isPending}>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Discard detection</DialogTitle>
<DialogDescription>
Remove {detectionLabel} from this ticket and record why the AI
result is incorrect.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-5">
<div className="space-y-2">
<Label htmlFor="discard-reason">Reason</Label>
<Select
value={reason}
onValueChange={(value) =>
setReason(value as DetectionDiscardReason)
}
disabled={isPending}
>
<SelectTrigger id="discard-reason" className="w-full">
<SelectValue placeholder="Select a reason" />
</SelectTrigger>
<SelectContent>
{DISCARD_REASON_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="discard-comment">Comment</Label>
<span className="text-xs text-muted-foreground">
Optional | {comment.length}/500
</span>
</div>
<Textarea
id="discard-comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Add details that can help improve future detections"
rows={4}
maxLength={500}
disabled={isPending}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
disabled={!reason || isPending}
>
{isPending ? <Loader2 className="animate-spin" /> : <Trash2 />}
{isPending ? 'Discarding' : 'Discard detection'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -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>
<CardHeader className="sm:grid-cols-[1fr_auto]">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-3 w-32 sm:col-start-2 sm:row-start-1" />
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="space-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-36" />
</div>
))}
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<div className="rounded-lg border bg-muted/15 px-4 py-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="mt-2 h-4 w-3/4" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Skeleton className="aspect-video w-full rounded-md" />
<Skeleton className="aspect-video w-full rounded-md" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="space-y-2 rounded-lg border p-2">
<Skeleton className="aspect-4/3 w-full rounded-lg" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" /> <Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-56" /> <Skeleton className="h-5 w-56 max-w-full" />
</div> </div>
</CardHeader>
<CardContent> <Timeline>
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="relative flex gap-4 pb-8 last:pb-0"> <TimelineEntrySkeleton
{index < 2 ? ( key={index}
<span className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" /> index={index}
) : null} isLast={index === 2}
<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> </Timeline>
</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>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react'; import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap'; import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -11,9 +11,14 @@ import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage'
import { getDefectVisual } from '@/constants/defectVisualConfig'; import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries'; import {
useDiscardDetectionMutation,
useTicketClassDetectionsQuery,
} from '../../hooks/useTicketQueries';
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketClassDetectionPreviewProps { interface TicketClassDetectionPreviewProps {
ticketId: string;
videoId?: string | null; videoId?: string | null;
defectClassName: string; defectClassName: string;
displayName: string; displayName: string;
@@ -89,12 +94,14 @@ function TicketClassDetectionPreviewSkeleton() {
} }
export function TicketClassDetectionPreview({ export function TicketClassDetectionPreview({
ticketId,
videoId, videoId,
defectClassName, defectClassName,
displayName, displayName,
totalCount, totalCount,
}: TicketClassDetectionPreviewProps) { }: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const visual = getDefectVisual(defectClassName); const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon; const IssueIcon = visual.icon;
@@ -116,15 +123,14 @@ export function TicketClassDetectionPreview({
videoId ?? undefined, videoId ?? undefined,
queryParams, queryParams,
); );
const discardMutation = useDiscardDetectionMutation(
ticketId,
videoId ?? undefined,
defectClassName,
);
const detectionResult = detectionsQuery.data; const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0]; const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0; const detectionCount = detectionResult?.total ?? totalCount ?? 0;
const mediaAspectRatio =
detectionResult &&
detectionResult.video_width > 0 &&
detectionResult.video_height > 0
? `${detectionResult.video_width} / ${detectionResult.video_height}`
: '16 / 9';
useEffect(() => { useEffect(() => {
if (detectionCount === 0 && currentIndex !== 0) { if (detectionCount === 0 && currentIndex !== 0) {
@@ -137,11 +143,8 @@ export function TicketClassDetectionPreview({
} }
}, [currentIndex, detectionCount]); }, [currentIndex, detectionCount]);
const isPreviousDisabled = currentIndex === 0 || detectionsQuery.isLoading; const isNavigationDisabled =
const isNextDisabled = detectionCount === 0 || detectionsQuery.isLoading;
detectionCount === 0 ||
currentIndex + 1 >= detectionCount ||
detectionsQuery.isLoading;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -149,7 +152,7 @@ export function TicketClassDetectionPreview({
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<span <span
className={cn( className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full', 'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
visual.cardClassName, visual.cardClassName,
)} )}
> >
@@ -157,13 +160,11 @@ export function TicketClassDetectionPreview({
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-lg font-semibold text-foreground"> <h3>{displayName}</h3>
{displayName}
</h3>
<Badge <Badge
variant="secondary" variant="secondary"
className={cn( className={cn(
'rounded-full border-0 px-2.5 py-1 text-xs font-semibold', 'rounded-lg',
visual.cardClassName, visual.cardClassName,
visual.colorClassName, visual.colorClassName,
)} )}
@@ -171,7 +172,7 @@ export function TicketClassDetectionPreview({
{detectionCount} Detections {detectionCount} Detections
</Badge> </Badge>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground">
Review detections in ascending timestamp order. Review detections in ascending timestamp order.
</p> </p>
</div> </div>
@@ -180,13 +181,18 @@ export function TicketClassDetectionPreview({
<div className="flex items-center gap-2 self-start sm:self-auto"> <div className="flex items-center gap-2 self-start sm:self-auto">
<Button <Button
type="button" type="button"
variant="outline" variant="secondary"
size="sm" onClick={() =>
onClick={() => setCurrentIndex((index) => Math.max(index - 1, 0))} setCurrentIndex((index) =>
disabled={isPreviousDisabled} detectionCount === 0
? 0
: (index - 1 + detectionCount) % detectionCount,
)
}
disabled={isNavigationDisabled}
aria-label="Previous detection"
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
Previous
</Button> </Button>
<div className="min-w-16 text-center text-sm font-medium text-foreground"> <div className="min-w-16 text-center text-sm font-medium text-foreground">
{detectionCount === 0 {detectionCount === 0
@@ -195,12 +201,15 @@ export function TicketClassDetectionPreview({
</div> </div>
<Button <Button
type="button" type="button"
variant="outline" variant="secondary"
size="sm" onClick={() =>
onClick={() => setCurrentIndex((index) => index + 1)} setCurrentIndex((index) =>
disabled={isNextDisabled} detectionCount === 0 ? 0 : (index + 1) % detectionCount,
)
}
disabled={isNavigationDisabled}
aria-label="Next detection"
> >
Next
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
</Button> </Button>
</div> </div>
@@ -233,14 +242,14 @@ export function TicketClassDetectionPreview({
<> <>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2"> <div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-muted-foreground">Detection Image</p>
Detection Image <div className="w-full overflow-hidden rounded-lg">
</p>
<div className="overflow-hidden rounded-lg">
<AnnotatedDetectionImage <AnnotatedDetectionImage
detection={activeDetection} detection={activeDetection}
videoWidth={detectionResult.video_width} videoWidth={detectionResult.video_width}
videoHeight={detectionResult.video_height} videoHeight={detectionResult.video_height}
aspectRatio="16 / 9"
enableLightbox
/> />
</div> </div>
</div> </div>
@@ -252,7 +261,7 @@ export function TicketClassDetectionPreview({
label={`${displayName} - Frame ${activeDetection.frame.number}`} label={`${displayName} - Frame ${activeDetection.frame.number}`}
title="Location on Map" title="Location on Map"
variant="plain" variant="plain"
aspectRatio={mediaAspectRatio} aspectRatio="16 / 9"
showCoordinates={false} showCoordinates={false}
/> />
</div> </div>
@@ -273,6 +282,31 @@ export function TicketClassDetectionPreview({
}, },
]} ]}
/> />
<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> </div>

View File

@@ -75,9 +75,9 @@ export function TicketDefectClassTabs({
if (defectClassesQuery.isLoading) { if (defectClassesQuery.isLoading) {
return ( return (
<Card className="w-full"> <Card className="w-full rounded-lg">
<CardContent className="space-y-4 p-5"> <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" />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<div <div
@@ -104,7 +104,7 @@ export function TicketDefectClassTabs({
> >
<Card className="w-full"> <Card className="w-full">
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border"> <CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
<div className="space-y-4 p-5"> <div className="space-y-4 px-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ListChecks className="size-5 shrink-0 text-primary" /> <ListChecks className="size-5 shrink-0 text-primary" />
<h2 className="text-base font-semibold">Detected Issues</h2> <h2 className="text-base font-semibold">Detected Issues</h2>
@@ -126,7 +126,7 @@ export function TicketDefectClassTabs({
key={item.class_name} key={item.class_name}
value={item.class_name} value={item.class_name}
className={cn( 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', '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',
'data-[state=active]:bg-muted data-[state=active]:text-foreground', 'data-[state=active]:bg-muted data-[state=active]:text-foreground',
)} )}
> >
@@ -135,7 +135,7 @@ export function TicketDefectClassTabs({
</span> </span>
<Badge <Badge
className={cn( className={cn(
'shrink-0 gap-1.5 rounded-full border-0 px-2.5 py-1 text-xs font-medium', 'shrink-0 rounded-full',
statusConfig.badgeClassName, statusConfig.badgeClassName,
)} )}
> >
@@ -148,8 +148,9 @@ export function TicketDefectClassTabs({
</TabsList> </TabsList>
</div> </div>
<div className="min-w-0 p-5"> <div className="min-w-0 px-5">
<TicketClassDetectionPreview <TicketClassDetectionPreview
ticketId={ticketId}
videoId={previewVideoId} videoId={previewVideoId}
defectClassName={selectedClass} defectClassName={selectedClass}
displayName={previewDisplayName} displayName={previewDisplayName}

View File

@@ -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" />

View File

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

View File

@@ -0,0 +1,68 @@
'use client';
import { useRef } from 'react';
import type { MediaPlayerInstance } from '@vidstack/react';
import { useProtectedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
import AnnotatedVideoPlayer from '@/components/video/annotatedVideoPlayer';
import { useVideoAnnotationPlayback } from '@/components/video/useVideoAnnotationPlayback';
import type { TicketOverviewDetail } from '@/types';
type TicketInlineAnalysisVideoProps = {
ticket: TicketOverviewDetail;
};
export function TicketInlineAnalysisVideo({
ticket,
}: TicketInlineAnalysisVideoProps) {
const videoRef = useRef<MediaPlayerInstance | null>(null);
const videoId = ticket.video_id ?? ticket.video?.id ?? undefined;
const sourceUrl = ticket.video?.url ?? undefined;
const fps = ticket.video_metadata?.fps ?? 0;
const videoWidth = ticket.video_metadata?.resolution.width ?? 0;
const videoHeight = ticket.video_metadata?.resolution.height ?? 0;
const {
visibleDetections,
handleSeeked,
handleTimeUpdate,
handleVideoFrame,
} = useVideoAnnotationPlayback({
videoId,
logs: [],
fps,
videoRef,
});
const {
videoUrl,
isLoading: isVideoLoading,
isError: isVideoError,
refetch: refetchVideo,
} = useProtectedVideoQuery(sourceUrl);
if (!videoId || !sourceUrl) {
return (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-sm text-muted-foreground">
Analysis video is unavailable for this ticket.
</div>
);
}
return (
<div className="space-y-2">
<AnnotatedVideoPlayer
videoRef={videoRef}
logs={[]}
visibleDetections={visibleDetections}
videoWidth={videoWidth}
videoHeight={videoHeight}
videoUrl={videoUrl}
isLoading={isVideoLoading}
isError={isVideoError}
onRetry={() => void refetchVideo()}
onTimeUpdate={handleTimeUpdate}
onVideoFrame={handleVideoFrame}
onSeeked={handleSeeked}
/>
</div>
);
}

View File

@@ -2,15 +2,15 @@
import { import {
Activity, Activity,
ArrowRight,
CalendarDays, CalendarDays,
Clock3, Clock3,
Gauge, Gauge,
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,
@@ -20,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 }>;
@@ -70,7 +79,7 @@ function SummaryCard({
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,
)} )}
> >
@@ -105,7 +114,7 @@ export function TicketOverviewCard({
}: { }: {
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
}) { }) {
const router = useRouter(); const [isAnalysisOpen, setIsAnalysisOpen] = useState(false);
const videoMetadata = ticket.video_metadata; const videoMetadata = ticket.video_metadata;
const duration = const duration =
typeof videoMetadata?.duration_seconds === 'number' typeof videoMetadata?.duration_seconds === 'number'
@@ -125,11 +134,9 @@ export function TicketOverviewCard({
const uploaderName = ticket.uploader?.name || null; const uploaderName = ticket.uploader?.name || null;
const analysisVideoId = ticket.video_id || ticket.video?.id || null; const analysisVideoId = ticket.video_id || ticket.video?.id || null;
const videoName = ticket.video?.name || null; const videoName = ticket.video?.name || null;
const analysisTitle = videoName || ticket.ticket_name || 'Analysis Video';
const hasVideoMetrics = Boolean( const hasVideoMetrics = Boolean(
videoName || videoMetadata?.fps || duration || videoMetadata?.resolution?.label,
videoMetadata?.fps ||
duration ||
videoMetadata?.resolution?.label,
); );
return ( return (
@@ -175,13 +182,11 @@ export function TicketOverviewCard({
<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/${analysisVideoId}`)}
> >
View Analysis <Play className="mr-2 size-4" />
<ArrowRight className="size-3.5" /> View Video
</Button> </Button>
</PermissionGuard> </PermissionGuard>
</CardAction> </CardAction>
@@ -210,9 +215,6 @@ export function TicketOverviewCard({
</div> </div>
{hasVideoMetrics ? ( {hasVideoMetrics ? (
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3"> <div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
{videoName ? (
<VideoMetric icon={Monitor} label={videoName} />
) : null}
{videoMetadata?.fps ? ( {videoMetadata?.fps ? (
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} /> <VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
) : null} ) : null}
@@ -229,6 +231,20 @@ export function TicketOverviewCard({
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{analysisTitle}</DialogTitle>
<DialogDescription>
Video analysis with annotation overlay
</DialogDescription>
</DialogHeader>
<DialogBody>
<TicketInlineAnalysisVideo ticket={ticket} />
</DialogBody>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -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="size-5 shrink-0 rounded-lg" />
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-24" /> <Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-32" /> <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> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-40" />
</div>
<CardAction>
<Skeleton className="h-9 w-28" />
</CardAction>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<SummarySkeletonCard key={index} />
))}
</div>
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex items-center justify-center gap-2"
>
<Skeleton className="size-4 rounded-lg" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
); );
} }

View File

@@ -4,13 +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 { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction';
interface TicketStatusActionsProps { interface TicketStatusActionsProps {
ticket: TicketDetail; ticket: TicketDetail;
@@ -37,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>
} }
> >
<>
<AssignedContractorAction ticket={ticket} />
<ReviewExtensionRequestAction ticket={ticket} /> <ReviewExtensionRequestAction ticket={ticket} />
</>
</PermissionGuard> </PermissionGuard>
); );
} }
@@ -52,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>
); );
} }
@@ -68,15 +71,5 @@ function getTicketAction(ticket: TicketDetail) {
} }
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) { export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
const action = getTicketAction(ticket); return <div className="space-y-3">{getTicketAction(ticket)}</div>;
const submittedProof = ticket.repair?.submitted_at ? (
<SubmittedRepairProofAction ticket={ticket} />
) : null;
return (
<div className="space-y-3">
{submittedProof}
{action}
</div>
);
} }

View File

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

View File

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

View File

@@ -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" />}

View File

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

View File

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

View File

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

View File

@@ -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"
variant="outline" className="w-full"
disabled={isPending} disabled={!canSubmit}
onClick={onCancel} onClick={handleSubmit}
> >
Cancel
</Button>
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
{isPending ? <Loader2 className="animate-spin" /> : <Send />} {isPending ? <Loader2 className="animate-spin" /> : <Send />}
{isPending ? 'Submitting...' : 'Submit Request'} {isPending ? 'Submitting...' : 'Submit Request'}
</Button> </Button>
</div> </div>
</div>
); );
} }

View File

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

View File

@@ -1,65 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Check, ClipboardCheck, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { TicketDetail } from '@/types';
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard';
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
const [reviewComment, setReviewComment] = useState('');
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
useEffect(() => {
setReviewComment(ticket.reviewer?.review_comment ?? '');
}, [ticket]);
return (
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
<div className="space-y-4">
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={reviewComment}
onChange={(event) => setReviewComment(event.target.value)}
placeholder="Repair verified"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
disabled={!reviewComment || reviewRepairMutation.isPending}
onClick={() =>
reviewRepairMutation.mutate({
defect_class: ticket.defect_class,
action: 'approve',
comment: reviewComment,
})
}
>
<Check />
Approve
</Button>
<Button
variant="destructive"
disabled={!reviewComment || reviewRepairMutation.isPending}
onClick={() =>
reviewRepairMutation.mutate({
defect_class: ticket.defect_class,
action: 'reject',
comment: reviewComment,
})
}
>
<X />
Reject
</Button>
</div>
</div>
</TicketActionCard>
);
}

View File

@@ -1,52 +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');
if (ticket.repair?.submitted_at) {
return null;
}
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>
);
}

View File

@@ -1 +0,0 @@
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';

View File

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

View File

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

View File

@@ -11,11 +11,15 @@ import { protectedMediaService } from '@/services/api';
interface RepairProofGalleryProps { interface RepairProofGalleryProps {
imageUrls: string[]; imageUrls: string[];
maxVisibleImages?: number;
} }
const MAX_VISIBLE_IMAGES = 8; const MAX_VISIBLE_IMAGES = 8;
export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) { export function RepairProofGallery({
imageUrls,
maxVisibleImages = MAX_VISIBLE_IMAGES,
}: RepairProofGalleryProps) {
const queries = useQueries({ const queries = useQueries({
queries: imageUrls.map((url) => ({ queries: imageUrls.map((url) => ({
queryKey: ['protected-media', 'v2', url], queryKey: ['protected-media', 'v2', url],
@@ -43,11 +47,11 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
if (queries.some((query) => query.isLoading)) { if (queries.some((query) => query.isLoading)) {
return ( return (
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-xl"> <div className="grid grid-cols-4 gap-1 overflow-hidden rounded-lg">
{imageUrls.slice(0, MAX_VISIBLE_IMAGES).map((url, index) => ( {imageUrls.slice(0, maxVisibleImages).map((url, index) => (
<Skeleton <Skeleton
key={`${url}-${index}`} key={`${url}-${index}`}
className="aspect-square rounded-none" className="aspect-square rounded-lg"
/> />
))} ))}
</div> </div>
@@ -57,7 +61,7 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
const images = objectUrls.flatMap((url, index) => const images = objectUrls.flatMap((url, index) =>
url ? [{ url, originalIndex: index }] : [], url ? [{ url, originalIndex: index }] : [],
); );
const visibleCount = Math.min(MAX_VISIBLE_IMAGES, images.length); const visibleCount = Math.min(maxVisibleImages, images.length);
const overflowCount = images.length - visibleCount; const overflowCount = images.length - visibleCount;
if (images.length === 0) { if (images.length === 0) {
@@ -70,7 +74,7 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
return ( return (
<LightGallery <LightGallery
elementClassNames="grid grid-cols-4 gap-1 overflow-hidden rounded-xl" elementClassNames="grid grid-cols-4 gap-1 overflow-hidden rounded-lg"
mode="lg-fade" mode="lg-fade"
speed={400} speed={400}
plugins={[lgThumbnail, lgZoom]} plugins={[lgThumbnail, lgZoom]}
@@ -102,8 +106,8 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90" className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90"
/> />
{isOverflowTile ? ( {isOverflowTile ? (
<span className="absolute inset-0 flex items-center justify-center bg-black/55 text-xl font-semibold text-white transition-colors group-hover:bg-black/65"> <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} +{overflowCount} more
</span> </span>
) : null} ) : null}
</a> </a>

View File

@@ -1,103 +0,0 @@
import { CheckCircle2, HardHat, Wrench } from 'lucide-react';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { RepairProofGallery } from './RepairProofGallery';
function SubmittedBanner({
submittedAt,
submitterName,
}: {
submittedAt: string;
submitterName: string;
}) {
return (
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
Repair Submitted
</p>
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
{formatDate(submittedAt)} by {submitterName}
</p>
</div>
</div>
);
}
function ImageGallery({
imageUrls,
hasVideo,
}: {
imageUrls: string[];
hasVideo: boolean;
}) {
if (imageUrls.length === 0) {
return hasVideo ? null : (
<div className="flex items-center gap-2 rounded-lg border border-dashed px-3 py-4 text-sm text-muted-foreground">
<HardHat className="size-4 shrink-0" />
No media attached
</div>
);
}
return <RepairProofGallery imageUrls={imageUrls} />;
}
function Notes({ notes }: { notes: string | null | undefined }) {
return (
<div className="space-y-1.5">
<p className="text-xs font-semibold text-foreground">Repair Notes</p>
<p className="text-sm leading-relaxed text-muted-foreground">
{notes?.trim() || 'No repair notes provided.'}
</p>
</div>
);
}
export function RepairProofSummary({ ticket }: { ticket: TicketDetail }) {
const repair = ticket.repair;
if (!repair?.submitted_at) {
return null;
}
const submitterName = ticket.worker?.name?.trim() || 'Assigned worker';
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Wrench className="size-4 text-primary" />
Repair Proof
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<SubmittedBanner
submittedAt={repair.submitted_at}
submitterName={submitterName}
/>
<ProtectedVideoPlayer
url={repair.proof_video_url}
meta="Contractor evidence video"
emptyTitle="No repair video uploaded"
emptyDescription="The assigned worker has not uploaded a repair video."
className="rounded-lg"
/>
<ImageGallery
imageUrls={repair.proof_image_urls}
hasVideo={Boolean(repair.proof_video_url)}
/>
<Notes notes={repair.notes} />
</CardContent>
</Card>
);
}

View File

@@ -58,15 +58,17 @@ 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">
<div className="xl:shrink-0">
{overview ? ( {overview ? (
<TicketDetailHeader ticket={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} /> <TicketOverviewCard ticket={overview} />
) : ( ) : (
@@ -79,7 +81,7 @@ export default function TicketDetailPage() {
) : 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">
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null} {isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? ( {!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} /> <TicketStatusActions ticket={classDetail} />

View File

@@ -1,20 +1,25 @@
'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, videoService } 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, VideoDetectionsParams,
} from '@/types'; } from '@/types';
@@ -117,6 +122,124 @@ export function useTicketClassDetectionsQuery(
}); });
} }
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,
@@ -171,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,
@@ -265,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();

View File

@@ -9,8 +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) => classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...ticketKeys.details(), 'video', videoId, 'detections', params] as const, [...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) =>

View File

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

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ export function UploadFileDropzone({
handleFiles(event.dataTransfer.files); handleFiles(event.dataTransfer.files);
}} }}
className={cn( className={cn(
'group flex min-h-32 cursor-pointer flex-col justify-between rounded-md border border-dashed bg-card/60 p-4 transition-colors', 'group flex min-h-32 cursor-pointer flex-col justify-between rounded-lg border border-dashed bg-card/60 p-4 transition-colors',
'hover:border-primary/60 hover:bg-secondary/40', 'hover:border-primary/60 hover:bg-secondary/40',
isDragging && 'border-primary bg-secondary/60', isDragging && 'border-primary bg-secondary/60',
disabled && 'pointer-events-none cursor-not-allowed opacity-60', disabled && 'pointer-events-none cursor-not-allowed opacity-60',
@@ -72,7 +72,7 @@ export function UploadFileDropzone({
/> />
<div className="space-y-3"> <div className="space-y-3">
<div className="flex size-9 items-center justify-center rounded-md border bg-background"> <div className="flex size-9 items-center justify-center rounded-lg border bg-background">
<UploadCloud className="size-4 text-muted-foreground" /> <UploadCloud className="size-4 text-muted-foreground" />
</div> </div>
<div> <div>

View File

@@ -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,7 +36,7 @@ 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}")` }
@@ -133,24 +130,7 @@ export function useUploadListColumns(
meta: { disableTruncate: true }, meta: { disableTruncate: true },
cell: ({ row }) => <UploadResultCell item={row.original} />, cell: ({ row }) => <UploadResultCell item={row.original} />,
}, },
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 64,
minSize: 64,
enableSorting: false,
enableHiding: false,
meta: { disableTruncate: true },
cell: ({ row }) => (
<div className="flex justify-center">
<UploadActionsMenu
upload={row.original}
onViewVideo={onViewVideo}
/>
</div>
),
},
], ],
[onViewVideo], [],
); );
} }

View File

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

View File

@@ -1,44 +0,0 @@
'use client';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { 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>
);
}

View File

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

View File

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

View File

@@ -1,84 +1,86 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './typography.css'; @import './typography.css';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--radius: 0.65rem; --radius: 0.225rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.541 0.281 293.009); --primary: oklch(0.457 0.24 277.023);
--primary-foreground: oklch(0.969 0.016 293.756); --primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.967 0.001 286.375); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.552 0.016 285.938); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.967 0.001 286.375); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.21 0.006 285.885); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32); --border: oklch(0.922 0 0);
--input: oklch(0.92 0.004 286.32); --input: oklch(0.922 0 0);
--ring: oklch(0.702 0.183 293.541); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.65 0.18 25); /* Soft Red/Rose - High Priority */ --chart-1: oklch(0.785 0.115 274.713); /* Soft Red/Rose - High Priority */
--chart-2: oklch(0.78 0.12 75); /* Warm Amber - Distinct */ --chart-2: oklch(0.585 0.233 277.117); /* Warm Amber - Distinct */
--chart-3: oklch(0.68 0.12 245); /* Azure Blue - Cool Professional */ --chart-3: oklch(0.511 0.262 276.966); /* Azure Blue - Cool Professional */
--chart-4: oklch(0.75 0.1 165); /* Mint Teal - Balanced */ --chart-4: oklch(0.457 0.24 277.023); /* Mint Teal - Balanced */
--chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */ --chart-5: oklch(0.398 0.195 277.366); /* Soft Cyan - Subdued */
--chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */ --chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */
--chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */ --chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.541 0.281 293.009); --sidebar-primary: oklch(0.511 0.262 276.966);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756); --sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.967 0.001 286.375); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.702 0.183 293.541); --sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
} }
.dark { .dark {
--background: oklch(0.141 0.005 285.823); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.606 0.25 292.717); --primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.969 0.016 293.756); --primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.705 0.015 286.067); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.274 0.006 286.033); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.38 0.189 293.745); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.7 0.16 25); --chart-1: oklch(0.785 0.115 274.713);
--chart-2: oklch(0.82 0.14 75); --chart-2: oklch(0.585 0.233 277.117);
--chart-3: oklch(0.72 0.14 245); --chart-3: oklch(0.511 0.262 276.966);
--chart-4: oklch(0.78 0.12 165); --chart-4: oklch(0.457 0.24 277.023);
--chart-5: oklch(0.88 0.1 195); --chart-5: oklch(0.398 0.195 277.366);
--chart-6: oklch(0.65 0.24 295); --chart-6: oklch(0.65 0.24 295);
--chart-8: oklch(0.62 0.22 335); --chart-8: oklch(0.62 0.22 335);
--sidebar: oklch(0.21 0.006 285.885); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.606 0.25 292.717); --sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756); --sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.38 0.189 293.745); --sidebar-ring: oklch(0.556 0 0);
} }
@theme inline { @theme inline {
--font-sans: 'Poppins', 'Poppins Fallback', system-ui, sans-serif; --font-sans: var(--font-sans);
--font-mono: 'Geist Mono', 'Geist Mono Fallback', monospace; --font-mono: 'Geist Mono', 'Geist Mono Fallback', monospace;
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
@@ -119,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 {

View File

@@ -1,5 +1,5 @@
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 'leaflet/dist/leaflet.css';
@@ -11,6 +11,10 @@ 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',
@@ -37,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>
@@ -50,8 +58,10 @@ export default function RootLayout({
> >
<QueryProvider> <QueryProvider>
<AppInitializer> <AppInitializer>
<TooltipProvider delayDuration={0}>
{children} {children}
<Toaster position="top-center" /> <Toaster position="top-center" />
</TooltipProvider>
</AppInitializer> </AppInitializer>
</QueryProvider> </QueryProvider>
</ThemeProvider> </ThemeProvider>

View File

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

View File

@@ -1,8 +1,8 @@
import { getDefectVisual } from '@/constants/defectVisualConfig'; import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem } from '@/types'; import type { BoundingBoxOverlayItem } from '@/types';
interface BoundingBoxOverlayProps { interface BoundingBoxOverlayProps {
detections: DetectionResultItem[]; detections: BoundingBoxOverlayItem[];
videoWidth: number; videoWidth: number;
videoHeight: number; videoHeight: number;
} }
@@ -23,7 +23,7 @@ export default function BoundingBoxOverlay({
viewBox={`0 0 ${videoWidth} ${videoHeight}`} viewBox={`0 0 ${videoWidth} ${videoHeight}`}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
> >
{detections.map(({ id, detection }) => { {detections.map((detection) => {
const { bounding_box: box } = detection; const { bounding_box: box } = detection;
const color = getDefectVisual(detection.class_name).boundingBoxColor; const color = getDefectVisual(detection.class_name).boundingBoxColor;
const label = `${detection.display_name} ${(detection.confidence * 100).toFixed(0)}%`; const label = `${detection.display_name} ${(detection.confidence * 100).toFixed(0)}%`;
@@ -31,7 +31,7 @@ export default function BoundingBoxOverlay({
const labelWidth = Math.max(150, label.length * 17); const labelWidth = Math.max(150, label.length * 17);
return ( return (
<g key={id}> <g key={detection.id}>
<rect <rect
x={box.x1} x={box.x1}
y={box.y1} y={box.y1}

View File

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

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { LogoWrapper } from '@/components/LogoWrapper'; import Image from 'next/image';
import { NavUser } from '@/components/nav-user'; import { NavUser } from '@/components/nav-user';
import { import {
filterMenuItems, filterMenuItems,
@@ -18,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>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

@@ -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()}`,
)} )}
/> />

View File

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

View File

@@ -116,7 +116,7 @@ export default function DetectionLocationMap({
return ( return (
<div className={cn('space-y-2', className)}> <div className={cn('space-y-2', className)}>
{title ? ( {title ? (
<p className="text-xs font-medium text-muted-foreground">{title}</p> <p className="text-muted-foreground">{title}</p>
) : null} ) : null}
{mapContent} {mapContent}
</div> </div>
@@ -127,7 +127,7 @@ export default function DetectionLocationMap({
<Card className={cn('overflow-hidden', className)}> <Card className={cn('overflow-hidden', className)}>
<CardHeader className="border-b pb-4"> <CardHeader className="border-b pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="rounded bg-secondary p-2"> <div className="rounded-lg bg-secondary p-2">
<MapPin className="h-5 w-5 text-primary" /> <MapPin className="h-5 w-5 text-primary" />
</div> </div>
<div> <div>

View File

@@ -41,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,
)} )}
> >
@@ -69,7 +69,7 @@ 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,
)} )}
> >
@@ -84,7 +84,7 @@ export function ProtectedVideoPlayer({
</video> </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}
@@ -110,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,
)} )}
> >

View File

@@ -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>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </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>
); );
} }

View File

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

View 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,
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,19 +1,16 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { import {
DayPicker, DayPicker,
getDefaultClassNames, getDefaultClassNames,
type DayButton, type DayButton,
type Locale,
} from "react-day-picker" } from "react-day-picker"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({ function Calendar({
className, className,
@@ -21,6 +18,7 @@ function Calendar({
showOutsideDays = true, showOutsideDays = true,
captionLayout = "label", captionLayout = "label",
buttonVariant = "ghost", buttonVariant = "ghost",
locale,
formatters, formatters,
components, components,
...props ...props
@@ -33,15 +31,16 @@ function Calendar({
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn( className={cn(
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", "group/calendar bg-background p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className className
)} )}
captionLayout={captionLayout} captionLayout={captionLayout}
locale={locale}
formatters={{ formatters={{
formatMonthDropdown: (date) => formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }), date.toLocaleString(locale?.code, { month: "short" }),
...formatters, ...formatters,
}} }}
classNames={{ classNames={{
@@ -74,7 +73,7 @@ function Calendar({
defaultClassNames.dropdowns defaultClassNames.dropdowns
), ),
dropdown_root: cn( dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50", "relative rounded-lg",
defaultClassNames.dropdown_root defaultClassNames.dropdown_root
), ),
dropdown: cn( dropdown: cn(
@@ -85,13 +84,13 @@ function Calendar({
"font-medium select-none", "font-medium select-none",
captionLayout === "label" captionLayout === "label"
? "text-sm" ? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", : "flex items-center gap-1 rounded-lg text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label defaultClassNames.caption_label
), ),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid), month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none", "flex-1 rounded-lg text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday defaultClassNames.weekday
), ),
week: cn("mt-2 flex w-full", defaultClassNames.week), week: cn("mt-2 flex w-full", defaultClassNames.week),
@@ -104,20 +103,23 @@ function Calendar({
defaultClassNames.week_number defaultClassNames.week_number
), ),
day: cn( day: cn(
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md", "group/day relative aspect-square h-full w-full rounded-lg p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-lg",
props.showWeekNumber props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" ? "[&:nth-child(2)[data-selected=true]_button]:rounded-lg"
: "[&:first-child[data-selected=true]_button]:rounded-l-md", : "[&:first-child[data-selected=true]_button]:rounded-lg",
defaultClassNames.day defaultClassNames.day
), ),
range_start: cn( range_start: cn(
"rounded-l-md bg-accent", "relative isolate z-0 rounded-lg bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start defaultClassNames.range_start
), ),
range_middle: cn("rounded-none", defaultClassNames.range_middle), range_middle: cn("rounded-lg", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), range_end: cn(
"relative isolate z-0 rounded-lg bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn( today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none", "rounded-lg bg-muted text-foreground data-[selected=true]:rounded-lg",
defaultClassNames.today defaultClassNames.today
), ),
outside: cn( outside: cn(
@@ -151,10 +153,7 @@ function Calendar({
if (orientation === "right") { if (orientation === "right") {
return ( return (
<ChevronRightIcon <ChevronRightIcon className={cn("size-4", className)} {...props} />
className={cn("size-4", className)}
{...props}
/>
) )
} }
@@ -162,7 +161,9 @@ function Calendar({
<ChevronDownIcon className={cn("size-4", className)} {...props} /> <ChevronDownIcon className={cn("size-4", className)} {...props} />
) )
}, },
DayButton: CalendarDayButton, DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
return ( return (
<td {...props}> <td {...props}>
@@ -183,8 +184,9 @@ function CalendarDayButton({
className, className,
day, day,
modifiers, modifiers,
locale,
...props ...props
}: React.ComponentProps<typeof DayButton>) { }: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null) const ref = React.useRef<HTMLButtonElement>(null)
@@ -197,7 +199,7 @@ function CalendarDayButton({
ref={ref} ref={ref}
variant="ghost" variant="ghost"
size="icon" size="icon"
data-day={day.date.toLocaleDateString()} data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={ data-selected-single={
modifiers.selected && modifiers.selected &&
!modifiers.range_start && !modifiers.range_start &&
@@ -208,7 +210,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end} data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle} data-range-middle={modifiers.range_middle}
className={cn( className={cn(
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70", "relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-lg data-[range-end=true]:rounded-lg data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-lg data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-lg data-[range-start=true]:rounded-lg data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className className
)} )}

View File

@@ -1,84 +1,95 @@
import * as React from 'react'; import * as React from "react"
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<'div'>) { function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return ( return (
<div <div
data-slot="card" data-slot="card"
data-size={size}
className={cn( className={cn(
'flex flex-col overflow-hidden rounded-xl border bg-card text-card-foreground', "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-lg bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-inset ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-lg *:[img:last-child]:rounded-lg",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 border-b px-5 py-4 has-data-[slot=card-action]:grid-cols-[1fr_auto]', "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-lg px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn('text-base leading-none font-semibold', className)} className={cn(
"font-heading text-base leading-normal font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props} {...props}
/> />
); )
} }
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn('text-sm text-muted-foreground', className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
); )
} }
function CardAction({ className, ...props }: React.ComponentProps<'div'>) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end', "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function CardContent({ className, ...props }: React.ComponentProps<'div'>) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn('px-5 py-5', className)} className={cn("px-(--card-spacing)", className)}
{...props} {...props}
/> />
); )
} }
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn('flex items-center border-t px-5 py-4', className)} className={cn(
"flex items-center rounded-lg px-(--card-spacing) [.border-t]:pt-(--card-spacing)",
className
)}
{...props} {...props}
/> />
); )
} }
export { export {
@@ -89,4 +100,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
}; }

View File

@@ -1,37 +1,42 @@
'use client'; "use client"
import * as React from 'react'; import * as React from "react"
import * as RechartsPrimitive from 'recharts'; import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const; const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = { const INITIAL_DIMENSION = { width: 320, height: 200 } as const
[k in string]: { type TooltipNameType = number | string
label?: React.ReactNode;
icon?: React.ComponentType; export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & ( } & (
| { color?: string; theme?: never } | { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> } | { color?: never; theme: Record<keyof typeof THEMES, string> }
); )
}; >
type ChartContextProps = { type ChartContextProps = {
config: ChartConfig; config: ChartConfig
}; }
const ChartContext = React.createContext<ChartContextProps | null>(null); const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() { function useChart() {
const context = React.useContext(ChartContext); const context = React.useContext(ChartContext)
if (!context) { if (!context) {
throw new Error('useChart must be used within a <ChartContainer />'); throw new Error("useChart must be used within a <ChartContainer />")
} }
return context; return context
} }
function ChartContainer({ function ChartContainer({
@@ -39,15 +44,20 @@ function ChartContainer({
className, className,
children, children,
config, config,
initialDimension = INITIAL_DIMENSION,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
config: ChartConfig; config: ChartConfig
children: React.ComponentProps< children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer typeof RechartsPrimitive.ResponsiveContainer
>['children']; >["children"]
initialDimension?: {
width: number
height: number
}
}) { }) {
const uniqueId = React.useId(); const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return ( return (
<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
@@ -55,27 +65,29 @@ function ChartContainer({
data-slot="chart" data-slot="chart"
data-chart={chartId} data-chart={chartId}
className={cn( className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className, className
)} )}
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} /> <ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children} {children}
</RechartsPrimitive.ResponsiveContainer> </RechartsPrimitive.ResponsiveContainer>
</div> </div>
</ChartContext.Provider> </ChartContext.Provider>
); )
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color, ([, config]) => config.theme ?? config.color
); )
if (!colorConfig.length) { if (!colorConfig.length) {
return null; return null
} }
return ( return (
@@ -88,27 +100,27 @@ ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color; itemConfig.color
return color ? ` --color-${key}: ${color};` : null; return color ? ` --color-${key}: ${color};` : null
}) })
.join('\n')} .join("\n")}
} }
`, `
) )
.join('\n'), .join("\n"),
}} }}
/> />
); )
}; }
const ChartTooltip = RechartsPrimitive.Tooltip; const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({ function ChartTooltipContent({
active, active,
payload, payload,
className, className,
indicator = 'dot', indicator = "dot",
hideLabel = false, hideLabel = false,
hideIndicator = false, hideIndicator = false,
label, label,
@@ -119,41 +131,47 @@ function ChartTooltipContent({
nameKey, nameKey,
labelKey, labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & { React.ComponentProps<"div"> & {
hideLabel?: boolean; hideLabel?: boolean
hideIndicator?: boolean; hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'; indicator?: "line" | "dot" | "dashed"
nameKey?: string; nameKey?: string
labelKey?: string; labelKey?: string
}) { } & Omit<
const { config } = useChart(); RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => { const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) { if (hideLabel || !payload?.length) {
return null; return null
} }
const [item] = payload; const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`; const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key); const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value = const value =
!labelKey && typeof label === 'string' !labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label ? (config[label]?.label ?? label)
: itemConfig?.label; : itemConfig?.label
if (labelFormatter) { if (labelFormatter) {
return ( return (
<div className={cn('font-medium', labelClassName)}> <div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)} {labelFormatter(value, payload)}
</div> </div>
); )
} }
if (!value) { if (!value) {
return null; return null
} }
return <div className={cn('font-medium', labelClassName)}>{value}</div>; return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [ }, [
label, label,
labelFormatter, labelFormatter,
@@ -162,34 +180,36 @@ function ChartTooltipContent({
labelClassName, labelClassName,
config, config,
labelKey, labelKey,
]); ])
if (!active || !payload?.length) { if (!active || !payload?.length) {
return null; return null
} }
const nestLabel = payload.length === 1 && indicator !== 'dot'; const nestLabel = payload.length === 1 && indicator !== "dot"
return ( return (
<div <div
className={cn( className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl', "grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className, className
)} )}
> >
{!nestLabel ? tooltipLabel : null} {!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{payload.map((item, index) => { {payload
const key = `${nameKey || item.name || item.dataKey || 'value'}`; .filter((item) => item.type !== "none")
const itemConfig = getPayloadConfigFromPayload(config, item, key); .map((item, index) => {
const indicatorColor = color || item.payload.fill || item.color; const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return ( return (
<div <div
key={item.dataKey} key={index}
className={cn( className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5', "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === 'dot' && 'items-center', indicator === "dot" && "items-center"
)} )}
> >
{formatter && item?.value !== undefined && item.name ? ( {formatter && item?.value !== undefined && item.name ? (
@@ -202,19 +222,19 @@ function ChartTooltipContent({
!hideIndicator && ( !hideIndicator && (
<div <div
className={cn( className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', "shrink-0 rounded-lg border-(--color-border) bg-(--color-bg)",
{ {
'h-2.5 w-2.5': indicator === 'dot', "h-2.5 w-2.5": indicator === "dot",
'w-1': indicator === 'line', "w-1": indicator === "line",
'w-0 border-[1.5px] border-dashed bg-transparent': "w-0 border-[1.5px] border-dashed bg-transparent":
indicator === 'dashed', indicator === "dashed",
'my-0.5': nestLabel && indicator === 'dashed', "my-0.5": nestLabel && indicator === "dashed",
}, }
)} )}
style={ style={
{ {
'--color-bg': indicatorColor, "--color-bg": indicatorColor,
'--color-border': indicatorColor, "--color-border": indicatorColor,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
@@ -222,75 +242,78 @@ function ChartTooltipContent({
)} )}
<div <div
className={cn( className={cn(
'flex flex-1 justify-between leading-none', "flex flex-1 justify-between leading-none",
nestLabel ? 'items-end' : 'items-center', nestLabel ? "items-end" : "items-center"
)} )}
> >
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null} {nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{itemConfig?.label || item.name} {itemConfig?.label ?? item.name}
</span> </span>
</div> </div>
{item.value && ( {item.value != null && (
<span className="text-foreground font-mono font-medium tabular-nums"> <span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()} {typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span> </span>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
); )
} }
const ChartLegend = RechartsPrimitive.Legend; const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({ function ChartLegendContent({
className, className,
hideIcon = false, hideIcon = false,
payload, payload,
verticalAlign = 'bottom', verticalAlign = "bottom",
nameKey, nameKey,
}: React.ComponentProps<'div'> & }: React.ComponentProps<"div"> & {
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & { hideIcon?: boolean
hideIcon?: boolean; nameKey?: string
nameKey?: string; } & RechartsPrimitive.DefaultLegendContentProps) {
}) { const { config } = useChart()
const { config } = useChart();
if (!payload?.length) { if (!payload?.length) {
return null; return null
} }
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-center gap-4', "flex items-center justify-center gap-4",
verticalAlign === 'top' ? 'pb-3' : 'pt-3', verticalAlign === "top" ? "pb-3" : "pt-3",
className, className
)} )}
> >
{payload.map((item) => { {payload
const key = `${nameKey || item.dataKey || 'value'}`; .filter((item) => item.type !== "none")
const itemConfig = getPayloadConfigFromPayload(config, item, key); .map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return ( return (
<div <div
key={item.value} key={index}
className={ className={cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3' "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
} )}
> >
{itemConfig?.icon && !hideIcon ? ( {itemConfig?.icon && !hideIcon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
<div <div
className="h-2 w-2 shrink-0 rounded-[2px]" className="h-2 w-2 shrink-0 rounded-lg"
style={{ style={{
backgroundColor: item.color, backgroundColor: item.color,
}} }}
@@ -298,49 +321,46 @@ function ChartLegendContent({
)} )}
{itemConfig?.label} {itemConfig?.label}
</div> </div>
); )
})} })}
</div> </div>
); )
} }
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload( function getPayloadConfigFromPayload(
config: ChartConfig, config: ChartConfig,
payload: unknown, payload: unknown,
key: string, key: string
) { ) {
if (typeof payload !== 'object' || payload === null) { if (typeof payload !== "object" || payload === null) {
return undefined; return undefined
} }
const payloadPayload = const payloadPayload =
'payload' in payload && "payload" in payload &&
typeof payload.payload === 'object' && typeof payload.payload === "object" &&
payload.payload !== null payload.payload !== null
? payload.payload ? payload.payload
: undefined; : undefined
let configLabelKey: string = key; let configLabelKey: string = key
if ( if (
key in payload && key in payload &&
typeof payload[key as keyof typeof payload] === 'string' typeof payload[key as keyof typeof payload] === "string"
) { ) {
configLabelKey = payload[key as keyof typeof payload] as string; configLabelKey = payload[key as keyof typeof payload] as string
} else if ( } else if (
payloadPayload && payloadPayload &&
key in payloadPayload && key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string' typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) { ) {
configLabelKey = payloadPayload[ configLabelKey = payloadPayload[
key as keyof typeof payloadPayload key as keyof typeof payloadPayload
] as string; ] as string
} }
return configLabelKey in config return configLabelKey in config ? config[configLabelKey] : config[key]
? config[configLabelKey]
: config[key as keyof typeof config];
} }
export { export {
@@ -350,4 +370,4 @@ export {
ChartLegend, ChartLegend,
ChartLegendContent, ChartLegendContent,
ChartStyle, ChartStyle,
}; }

View File

@@ -1,11 +1,11 @@
'use client'; "use client"
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({ function Collapsible({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
} }
function CollapsibleTrigger({ function CollapsibleTrigger({
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger" data-slot="collapsible-trigger"
{...props} {...props}
/> />
); )
} }
function CollapsibleContent({ function CollapsibleContent({
@@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content" data-slot="collapsible-content"
{...props} {...props}
/> />
); )
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent }; export { Collapsible, CollapsibleTrigger, CollapsibleContent }

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