Compare commits
27 Commits
7a82e17bbb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 224361f144 | |||
| 77b6755d05 | |||
| 2f25fd49be | |||
| 3891500cf2 | |||
| 062e89fcba | |||
| 725c256791 | |||
| 5d3dac0b21 | |||
| e077467bce | |||
| e94da54aa9 | |||
| 76c87811d8 | |||
| 830883c6b7 | |||
| 68829dc72b | |||
| 9d7f956c3b | |||
| 0026824f84 | |||
| a18eb64dac | |||
| 6c71d8f8a7 | |||
| 0e2172366b | |||
| 56180f8d18 | |||
| 5269b29aac | |||
| 9fe7109faf | |||
| 9720546455 | |||
| 8727963c54 | |||
| 0e8108b875 | |||
| 87dde1c8d1 | |||
| a376558b2f | |||
| 4fe93460d9 | |||
| cbfb6e040b |
@@ -1,2 +1,2 @@
|
||||
# Backend API base URL (no trailing slash)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
# Server-only backend origin. Do not expose this as a NEXT_PUBLIC variable.
|
||||
BACKEND_ORIGIN=http://localhost:8000
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "radix-vega",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -18,5 +19,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const backendOrigin = process.env.BACKEND_ORIGIN?.replace(/\/+$/, '');
|
||||
|
||||
if (!backendOrigin) {
|
||||
throw new Error('BACKEND_ORIGIN is required');
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/:namespace(api|biz)/:path*',
|
||||
destination: `${backendOrigin}/:namespace/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
3320
package-lock.json
generated
3320
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,8 @@
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "2.15.4",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
5
public/color_logo.svg
Normal file
5
public/color_logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
public/logo.svg
Normal file
5
public/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -33,7 +33,7 @@ function ResetPasswordContent() {
|
||||
</header>
|
||||
|
||||
{!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.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
|
||||
|
||||
export function ModuleShellHeader() {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-backdrop-filter:bg-background/80">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SidebarTrigger className="shrink-0 bg-background/80 text-muted-foreground shadow-xs backdrop-blur-sm" />
|
||||
<Separator orientation="vertical" className="h-4 shrink-0" />
|
||||
</>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { FormField, PhoneInput } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -71,12 +72,12 @@ export function ClientSheet({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{clientId ? 'Edit Client' : 'Create Client'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage company and primary contact details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -86,7 +87,7 @@ export function ClientSheet({
|
||||
onSubmit={onSubmit}
|
||||
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">
|
||||
<FormField
|
||||
id="client-first-name"
|
||||
@@ -240,9 +241,9 @@ export function ClientSheet({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AuthGuard } from '@/guards';
|
||||
import React from 'react';
|
||||
import { ModuleShellHeader } from './_components/module-shell-header';
|
||||
|
||||
const ModulesLayout = ({
|
||||
children,
|
||||
@@ -15,16 +13,11 @@ const ModulesLayout = ({
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<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="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
|
||||
<SidebarTrigger />
|
||||
<ModeToggle />
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
<div className="p-6 flex-1">{children}</div>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-380 flex-col">
|
||||
<ModuleShellHeader />
|
||||
<div className="flex-1 p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -62,14 +63,14 @@ export function PackageDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{packageId ? 'Edit Package' : 'Create Package'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage package details and optional chainage range.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -79,7 +80,7 @@ export function PackageDialog({
|
||||
onSubmit={onSubmit}
|
||||
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">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
@@ -173,9 +174,9 @@ export function PackageDialog({
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Package as PackageIcon, Plus } from 'lucide-react';
|
||||
|
||||
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 type { Package } from '@/types';
|
||||
|
||||
@@ -20,6 +30,7 @@ import {
|
||||
|
||||
export default function PackagePage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = usePackageFilters();
|
||||
const packagesQuery = usePackagesQuery({ skip, limit });
|
||||
const projectsQuery = useProjectOptionsQuery();
|
||||
@@ -67,14 +78,25 @@ export default function PackagePage() {
|
||||
|
||||
const deletePackage = useCallback(
|
||||
(pkg: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
deletePackageMutation.mutate(pkg);
|
||||
setPackageToDelete(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 packages = packagesQuery.data?.items ?? [];
|
||||
const total = packagesQuery.data?.totalItems ?? 0;
|
||||
@@ -122,6 +144,36 @@ export default function PackagePage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(packageToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete package?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{packageToDelete?.name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deletePackageMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePackageMutation.isPending}
|
||||
onClick={confirmDeletePackage}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function PlanSheet({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
{planId ? 'Edit Plan' : 'Create Plan'}
|
||||
@@ -77,7 +77,11 @@ export function PlanSheet({
|
||||
</SheetDescription>
|
||||
</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="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
@@ -242,7 +246,7 @@ export function PlanSheet({
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
className="size-4 rounded-lg border-border accent-primary"
|
||||
{...register('is_active')}
|
||||
/>
|
||||
Active
|
||||
@@ -250,7 +254,7 @@ export function PlanSheet({
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
className="size-4 rounded-lg border-border accent-primary"
|
||||
{...register('is_custom')}
|
||||
/>
|
||||
Custom plan
|
||||
@@ -269,7 +273,7 @@ export function PlanSheet({
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FormField } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -54,14 +55,14 @@ export function ProjectDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{projectId ? 'Edit Project' : 'Create Project'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage road project identity and optional coordinate boundaries.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -71,7 +72,7 @@ export function ProjectDialog({
|
||||
onSubmit={onSubmit}
|
||||
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">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
@@ -192,9 +193,9 @@ export function ProjectDialog({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Layers, Plus } from 'lucide-react';
|
||||
|
||||
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 type { Project } from '@/types';
|
||||
|
||||
@@ -19,6 +29,7 @@ import {
|
||||
|
||||
export default function ProjectPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = useProjectFilters();
|
||||
const projectsQuery = useProjectsQuery({ skip, limit });
|
||||
const deleteProjectMutation = useDeleteProjectMutation();
|
||||
@@ -63,16 +74,25 @@ export default function ProjectPage() {
|
||||
|
||||
const deleteProject = useCallback(
|
||||
(project: Project) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete project "${project.name}"?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteProjectMutation.mutate(project);
|
||||
setProjectToDelete(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 projects = projectsQuery.data?.items ?? [];
|
||||
const total = projectsQuery.data?.totalItems ?? 0;
|
||||
@@ -117,6 +137,36 @@ export default function ProjectPage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(projectToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{projectToDelete?.name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={confirmDeleteProject}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
function StatSkeleton() {
|
||||
return (
|
||||
<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="mt-2 h-3 w-20" />
|
||||
</div>
|
||||
@@ -14,9 +14,9 @@ function StatSkeleton() {
|
||||
|
||||
function DetectionLogSkeleton() {
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-3">
|
||||
<div className="rounded-lg border bg-card p-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="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
@@ -41,7 +41,7 @@ export function VideoResultsSkeleton() {
|
||||
<div className="space-y-8">
|
||||
<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">
|
||||
<Skeleton className="size-10 rounded-md" />
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-7 w-60 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" />
|
||||
</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="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded" />
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-44" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
@@ -76,7 +76,7 @@ export function VideoResultsSkeleton() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
||||
{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>
|
||||
</section>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function RoleSheet({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
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">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
@@ -73,7 +73,11 @@ export function RoleSheet({
|
||||
</SheetDescription>
|
||||
</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="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
@@ -125,7 +129,7 @@ export function RoleSheet({
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -71,14 +72,14 @@ export function SegmentDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage segment binding, chainage values, direction, and coordinates.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -88,7 +89,7 @@ export function SegmentDialog({
|
||||
onSubmit={onSubmit}
|
||||
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">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
@@ -300,9 +301,9 @@ export function SegmentDialog({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Milestone, Plus } from 'lucide-react';
|
||||
|
||||
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 type { Chainage } from '@/types';
|
||||
|
||||
@@ -21,6 +31,7 @@ import {
|
||||
|
||||
export default function SegmentPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = useSegmentFilters();
|
||||
const segmentsQuery = useSegmentsQuery({ skip, limit });
|
||||
const projectsQuery = useProjectOptionsQuery();
|
||||
@@ -75,18 +86,25 @@ export default function SegmentPage() {
|
||||
|
||||
const deleteSegment = useCallback(
|
||||
(segment: Chainage) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete segment "${segment.segment_name}"?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteSegmentMutation.mutate(segment);
|
||||
setSegmentToDelete(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 allPackages = allPackagesQuery.data?.items ?? [];
|
||||
const segments = segmentsQuery.data?.items ?? [];
|
||||
@@ -139,6 +157,36 @@ export default function SegmentPage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(segmentToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete segment?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{segmentToDelete?.segment_name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteSegmentMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteSegmentMutation.isPending}
|
||||
onClick={confirmDeleteSegment}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FormField, PhoneInput, SelectPopover } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -98,12 +99,12 @@ export function TenantSheet({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Bind a client and subscription plan, then invite the tenant
|
||||
administrator.
|
||||
</DialogDescription>
|
||||
@@ -114,7 +115,7 @@ export function TenantSheet({
|
||||
onSubmit={onSubmit}
|
||||
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">
|
||||
<p>Basic Information</p>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -227,7 +228,7 @@ export function TenantSheet({
|
||||
id="tenant-description"
|
||||
placeholder="North zone operations"
|
||||
{...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>
|
||||
</div>
|
||||
@@ -314,7 +315,7 @@ export function TenantSheet({
|
||||
</div>
|
||||
</>
|
||||
) : 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
|
||||
creation.
|
||||
<p className="mt-1 text-foreground">
|
||||
@@ -322,9 +323,9 @@ export function TenantSheet({
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { RepairProofGallery } from '../../../components/repair/RepairProofGallery';
|
||||
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
|
||||
|
||||
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
|
||||
not_submitted: 'Awaiting Repair',
|
||||
pending: 'Under Review',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
};
|
||||
|
||||
const REVIEW_STATUS_CLASS: Record<IssueReviewStatus, string> = {
|
||||
not_submitted: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||
pending: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
approved: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
rejected: 'bg-destructive/10 text-destructive',
|
||||
};
|
||||
|
||||
function MetadataItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1 px-4 first:pl-0 last:pr-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueEvidencePanelProps {
|
||||
detection: DetectionResultItem;
|
||||
issueNumber: number;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
reviewStatus: IssueReviewStatus;
|
||||
}
|
||||
|
||||
export function IssueEvidencePanel({
|
||||
detection,
|
||||
issueNumber,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
reviewStatus,
|
||||
}: IssueEvidencePanelProps) {
|
||||
const repairProof = detection.repair_proof;
|
||||
const displayName = detection.detection.display_name;
|
||||
const confidence = Math.round(detection.detection.confidence * 100);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">
|
||||
{displayName} #{issueNumber}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(REVIEW_STATUS_CLASS[reviewStatus])}
|
||||
>
|
||||
{REVIEW_STATUS_LABEL[reviewStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 divide-x sm:grid-cols-4">
|
||||
<MetadataItem
|
||||
label="Video Timestamp"
|
||||
value={formatVideoTimestamp(detection.frame.timestamp_seconds)}
|
||||
/>
|
||||
<MetadataItem label="Frame" value={detection.frame.number} />
|
||||
<MetadataItem label="Confidence" value={`${confidence}%`} />
|
||||
<MetadataItem label="Issue ID" value={detection.detection.id} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ImageIcon className="size-4 text-primary" />
|
||||
Detection Image (Before)
|
||||
</p>
|
||||
<AnnotatedDetectionImage
|
||||
detection={detection}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
aspectRatio="16 / 9"
|
||||
enableLightbox
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MapPin className="size-4 text-primary" />
|
||||
Detection Location
|
||||
</p>
|
||||
<DetectionLocationMap
|
||||
latitude={detection.location.latitude}
|
||||
longitude={detection.location.longitude}
|
||||
label={`${displayName} #${issueNumber} - Frame ${detection.frame.number}`}
|
||||
title=""
|
||||
variant="plain"
|
||||
aspectRatio="16 / 9"
|
||||
showCoordinates={false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<CalendarCheck2 className="size-4 text-primary" />
|
||||
Repair Proof (After Repair)
|
||||
</p>
|
||||
{repairProof?.submitted ? (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Smartphone />
|
||||
Uploaded from App
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{!repairProof?.submitted ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Repair proof is not submitted yet.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This issue cannot be reviewed until repair evidence is
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{repairProof.submitted_at ? (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
Uploaded from the field app on{' '}
|
||||
{formatDate(repairProof.submitted_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RepairProofGallery
|
||||
imageUrls={repairProof.proof_image_urls}
|
||||
maxVisibleImages={4}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5 rounded-lg border bg-card p-4 shadow-xs">
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
Repair Notes
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{repairProof.notes?.trim() ||
|
||||
'No repair notes were provided.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Info,
|
||||
Loader2,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
DetectionResultItem,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import type { IssueReviewStatus } from './reviewTypes';
|
||||
|
||||
type ReviewDecision = ReviewDetectionRepairProofPayload['action'];
|
||||
|
||||
interface IssueReviewActionProps {
|
||||
detection: DetectionResultItem;
|
||||
issueLabel: string;
|
||||
status: IssueReviewStatus;
|
||||
isPending: boolean;
|
||||
onSave: (payload: ReviewDetectionRepairProofPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export function IssueReviewAction({
|
||||
detection,
|
||||
issueLabel,
|
||||
status,
|
||||
isPending,
|
||||
onSave,
|
||||
}: IssueReviewActionProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const [decision, setDecision] = useState<ReviewDecision>();
|
||||
|
||||
useEffect(() => {
|
||||
setComment('');
|
||||
setDecision(undefined);
|
||||
}, [detection.detection.id]);
|
||||
|
||||
if (status === 'not_submitted') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review & Action
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-center">
|
||||
<Info className="mx-auto size-6 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">Repair proof required</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{issueLabel} is not ready for review.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'approved' || status === 'rejected') {
|
||||
const isApproved = status === 'approved';
|
||||
const repairProof = detection.repair_proof;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review Result
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border px-4 py-5 text-center',
|
||||
isApproved
|
||||
? 'border-emerald-500/20 bg-emerald-500/5'
|
||||
: 'border-destructive/20 bg-destructive/5',
|
||||
)}
|
||||
>
|
||||
{isApproved ? (
|
||||
<CheckCircle2 className="mx-auto size-8 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mx-auto size-8 text-destructive" />
|
||||
)}
|
||||
<Badge
|
||||
className={cn(
|
||||
'mt-3',
|
||||
isApproved
|
||||
? 'bg-emerald-500/10 text-emerald-600'
|
||||
: 'bg-destructive/10 text-destructive',
|
||||
)}
|
||||
>
|
||||
{isApproved ? 'Approved' : 'Rejected'}
|
||||
</Badge>
|
||||
{repairProof?.reviewed_by_name || repairProof?.reviewed_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{repairProof.reviewed_by_name
|
||||
? `By ${repairProof.reviewed_by_name}`
|
||||
: 'Reviewed'}
|
||||
{repairProof.reviewed_at
|
||||
? ` on ${formatDate(repairProof.reviewed_at)}`
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{repairProof?.review_comment ? (
|
||||
<div className="space-y-1.5 rounded-lg bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold">Review Comment</p>
|
||||
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
|
||||
{repairProof.review_comment}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const submitDecision = async (nextDecision: ReviewDecision) => {
|
||||
setDecision(nextDecision);
|
||||
|
||||
try {
|
||||
await onSave({
|
||||
action: nextDecision,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
// The mutation owns user-facing error feedback.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review & Action
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="review-comment">Comment (Optional)</Label>
|
||||
<Textarea
|
||||
id="review-comment"
|
||||
value={comment}
|
||||
maxLength={4000}
|
||||
rows={5}
|
||||
disabled={isPending}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Enter your review comments..."
|
||||
/>
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{comment.length} / 4000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Decision</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
className={cn(
|
||||
decision === 'approve' &&
|
||||
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
|
||||
)}
|
||||
>
|
||||
{isPending && decision === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
className={cn(
|
||||
decision === 'reject' &&
|
||||
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
|
||||
)}
|
||||
>
|
||||
{isPending && decision === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<X />
|
||||
)}
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { ProofStatusFilter } from './reviewTypes';
|
||||
|
||||
const PROOF_STATUS_FILTERS: Array<{
|
||||
value: ProofStatusFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
];
|
||||
|
||||
interface IssueReviewHeaderProps {
|
||||
ticketLabel: string;
|
||||
defectLabel: string;
|
||||
currentIndex: number;
|
||||
total: number;
|
||||
proofStatus: ProofStatusFilter;
|
||||
onBack: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onProofStatusChange: (status: ProofStatusFilter) => void;
|
||||
}
|
||||
|
||||
export function IssueReviewHeader({
|
||||
ticketLabel,
|
||||
defectLabel,
|
||||
currentIndex,
|
||||
total,
|
||||
proofStatus,
|
||||
onBack,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onProofStatusChange,
|
||||
}: IssueReviewHeaderProps) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
aria-label="Back to ticket"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight">
|
||||
{defectLabel} Review
|
||||
</h1>
|
||||
<Badge variant="secondary">{ticketLabel}</Badge>
|
||||
</div>
|
||||
<div
|
||||
className="flex max-w-full flex-nowrap items-center gap-1.5 overflow-x-auto pt-1 pb-1"
|
||||
aria-label="Filter issues by repair proof status"
|
||||
>
|
||||
{PROOF_STATUS_FILTERS.map((filter) => {
|
||||
const isActive = proofStatus === filter.value;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => onProofStatusChange(filter.value)}
|
||||
className="h-7 shrink-0 rounded-full px-3 text-xs"
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={total === 0 || currentIndex <= 0}
|
||||
onClick={onPrevious}
|
||||
aria-label="Previous issue"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<div className="min-w-16 text-center text-sm font-semibold">
|
||||
{total === 0 ? '0 of 0' : `${currentIndex + 1} of ${total}`}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={total === 0 || currentIndex >= total - 1}
|
||||
onClick={onNext}
|
||||
aria-label="Next issue"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Hourglass,
|
||||
ListChecks,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
|
||||
import {
|
||||
formatVideoTimestamp,
|
||||
getIssueId,
|
||||
getIssueReviewStatus,
|
||||
type IssueReviewStatus,
|
||||
} from './reviewTypes';
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
IssueReviewStatus,
|
||||
{
|
||||
label: string;
|
||||
icon: typeof Hourglass;
|
||||
className: string;
|
||||
}
|
||||
> = {
|
||||
not_submitted: {
|
||||
label: 'Awaiting Repair',
|
||||
icon: Wrench,
|
||||
className: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||
},
|
||||
pending: {
|
||||
label: 'Under Review',
|
||||
icon: Hourglass,
|
||||
className: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
icon: CheckCircle2,
|
||||
className: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
icon: XCircle,
|
||||
className: 'bg-destructive/10 text-destructive',
|
||||
},
|
||||
};
|
||||
|
||||
interface IssueReviewQueueProps {
|
||||
detections: DetectionResultItem[];
|
||||
totalCount: number;
|
||||
selectedIssueId?: string;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onSelect: (issueId: string) => void;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
export function IssueReviewQueue({
|
||||
detections,
|
||||
totalCount,
|
||||
selectedIssueId,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onSelect,
|
||||
onLoadMore,
|
||||
}: IssueReviewQueueProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const defectLabel = detections[0]?.detection.display_name ?? 'Detected';
|
||||
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || !hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const root = target.closest<HTMLElement>(
|
||||
'[data-slot="scroll-area-viewport"]',
|
||||
);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) onLoadMore();
|
||||
},
|
||||
{ root, rootMargin: '160px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, onLoadMore]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden xl:h-full xl:min-h-0">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ListChecks className="size-5 text-primary" />
|
||||
{defectLabel} Issues
|
||||
<Badge variant="secondary" className="ml-auto rounded-full">
|
||||
{totalCount}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-2">
|
||||
<ScrollArea className="h-112 pr-2 xl:h-full">
|
||||
<div className="space-y-2 p-1">
|
||||
{detections.map((detection, index) => {
|
||||
const issueId = getIssueId(detection);
|
||||
const status = getIssueReviewStatus(detection);
|
||||
const config = STATUS_CONFIG[status];
|
||||
const StatusIcon = config.icon;
|
||||
const isSelected = issueId === selectedIssueId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={detection.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(issueId)}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'w-full rounded-lg border p-3 text-left transition-colors',
|
||||
'hover:border-primary/40 hover:bg-muted/50',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/6 shadow-sm'
|
||||
: 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{defectLabel} #{index + 1}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{defectLabel} #{index + 1}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock3 className="size-3.5" />
|
||||
{formatVideoTimestamp(
|
||||
detection.frame.timestamp_seconds,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('shrink-0 px-1.5', config.className)}
|
||||
>
|
||||
<StatusIcon />
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span aria-hidden="true">|</span>
|
||||
<span>Frame {detection.frame.number}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div ref={loadMoreRef} className="space-y-2 py-1">
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
Scroll to load more issues
|
||||
</p>
|
||||
) : detections.length > 0 ? (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
All {totalCount} issues loaded
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function IssueReviewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)_330px]">
|
||||
{[280, 600, 330].map((width) => (
|
||||
<Card key={width}>
|
||||
<CardHeader className="border-b">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-20 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { DetectionProofStatus, DetectionResultItem } from '@/types';
|
||||
|
||||
export type ProofStatusFilter =
|
||||
| 'all'
|
||||
| Extract<DetectionProofStatus, 'pending' | 'approved' | 'rejected'>;
|
||||
|
||||
export type IssueReviewStatus =
|
||||
| 'not_submitted'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected';
|
||||
|
||||
export function getIssueId(detection: DetectionResultItem) {
|
||||
return String(detection.detection.id);
|
||||
}
|
||||
|
||||
export function getIssueReviewStatus(
|
||||
detection: DetectionResultItem,
|
||||
): IssueReviewStatus {
|
||||
const repairProof = detection.repair_proof;
|
||||
if (!repairProof?.submitted) return 'not_submitted';
|
||||
|
||||
return repairProof.review_status;
|
||||
}
|
||||
|
||||
export function formatVideoTimestamp(totalSeconds: number) {
|
||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
|
||||
return `${minutes}:${remainder.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
usePathname,
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from 'next/navigation';
|
||||
import { AlertTriangle, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import type { ReviewDetectionRepairProofPayload } from '@/types';
|
||||
|
||||
import {
|
||||
useReviewDetectionRepairProofMutation,
|
||||
useTicketClassReviewDetectionsQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
import { IssueEvidencePanel } from './components/IssueEvidencePanel';
|
||||
import { IssueReviewAction } from './components/IssueReviewAction';
|
||||
import { IssueReviewHeader } from './components/IssueReviewHeader';
|
||||
import { IssueReviewQueue } from './components/IssueReviewQueue';
|
||||
import { IssueReviewSkeleton } from './components/IssueReviewSkeleton';
|
||||
import {
|
||||
getIssueId,
|
||||
getIssueReviewStatus,
|
||||
type ProofStatusFilter,
|
||||
} from './components/reviewTypes';
|
||||
|
||||
const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
|
||||
'all',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
];
|
||||
|
||||
export default function TicketClassReviewPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { ticketId, defectClass } = useParams() as {
|
||||
ticketId: string;
|
||||
defectClass: string;
|
||||
};
|
||||
const proofStatusParam = searchParams.get('proof_status');
|
||||
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
|
||||
proofStatusParam as ProofStatusFilter,
|
||||
)
|
||||
? (proofStatusParam as ProofStatusFilter)
|
||||
: 'all';
|
||||
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const ticket = overviewQuery.data;
|
||||
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
|
||||
const detectionsQuery = useTicketClassReviewDetectionsQuery(
|
||||
videoId,
|
||||
defectClass,
|
||||
proofStatus === 'all' ? 'submitted' : proofStatus,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data?.pages[0];
|
||||
const detections = useMemo(
|
||||
() => detectionsQuery.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[detectionsQuery.data?.pages],
|
||||
);
|
||||
const totalCount = detectionResult?.total ?? 0;
|
||||
const selectedIssueParam = searchParams.get('issue_id') ?? undefined;
|
||||
const reviewMutation = useReviewDetectionRepairProofMutation({
|
||||
ticketId,
|
||||
videoId,
|
||||
defectClass,
|
||||
});
|
||||
|
||||
const selectIssue = useCallback(
|
||||
(issueId: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set('issue_id', issueId);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const handleProofStatusChange = useCallback(
|
||||
(status: ProofStatusFilter) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
if (status === 'all') {
|
||||
nextParams.delete('proof_status');
|
||||
} else {
|
||||
nextParams.set('proof_status', status);
|
||||
}
|
||||
nextParams.delete('issue_id');
|
||||
const query = nextParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detections.length) return;
|
||||
|
||||
const hasSelectedIssue = detections.some(
|
||||
(detection) => getIssueId(detection) === selectedIssueParam,
|
||||
);
|
||||
if (hasSelectedIssue) return;
|
||||
|
||||
if (
|
||||
selectedIssueParam &&
|
||||
detectionsQuery.hasNextPage &&
|
||||
!detectionsQuery.isFetchingNextPage
|
||||
) {
|
||||
void detectionsQuery.fetchNextPage();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstReviewable = detections.find(
|
||||
(detection) => getIssueReviewStatus(detection) === 'pending',
|
||||
);
|
||||
selectIssue(getIssueId(firstReviewable ?? detections[0]));
|
||||
}, [detections, detectionsQuery, selectIssue, selectedIssueParam]);
|
||||
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
detections.findIndex(
|
||||
(detection) => getIssueId(detection) === selectedIssueParam,
|
||||
),
|
||||
);
|
||||
const activeDetection = detections[activeIndex];
|
||||
const activeIssueId = activeDetection
|
||||
? getIssueId(activeDetection)
|
||||
: undefined;
|
||||
const activeReviewStatus = activeDetection
|
||||
? getIssueReviewStatus(activeDetection)
|
||||
: 'not_submitted';
|
||||
|
||||
const handleSaveReview = useCallback(
|
||||
async (payload: ReviewDetectionRepairProofPayload) => {
|
||||
if (!activeDetection) return;
|
||||
|
||||
await reviewMutation.mutateAsync({
|
||||
detectionId: activeDetection.detection.id,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
[activeDetection, reviewMutation],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
if (activeIndex < detections.length - 1) {
|
||||
selectIssue(getIssueId(detections[activeIndex + 1]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detectionsQuery.hasNextPage) return;
|
||||
const nextResult = await detectionsQuery.fetchNextPage();
|
||||
const nextDetections =
|
||||
nextResult.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
const nextDetection = nextDetections[activeIndex + 1];
|
||||
if (nextDetection) selectIssue(getIssueId(nextDetection));
|
||||
}, [activeIndex, detections, detectionsQuery, selectIssue]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (detectionsQuery.hasNextPage && !detectionsQuery.isFetchingNextPage) {
|
||||
void detectionsQuery.fetchNextPage();
|
||||
}
|
||||
}, [detectionsQuery]);
|
||||
|
||||
const backToTicket = () => {
|
||||
const params = new URLSearchParams({ defect_class: defectClass });
|
||||
router.push(`${ROUTES.TICKET_DETAIL(ticketId)}?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) {
|
||||
return <IssueReviewSkeleton />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !ticket || !videoId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Unable to open the review workspace.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ticket or video information is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back to Ticket
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (detectionsQuery.isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">Failed to load class detections.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The review screen uses the detection API for its queue and
|
||||
evidence.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void detectionsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeDetection || !detectionResult) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<IssueReviewHeader
|
||||
ticketLabel={ticket.ticket_name || ticket.id}
|
||||
defectLabel={defectClass}
|
||||
currentIndex={0}
|
||||
total={0}
|
||||
proofStatus={proofStatus}
|
||||
onBack={backToTicket}
|
||||
onPrevious={() => undefined}
|
||||
onNext={() => undefined}
|
||||
onProofStatusChange={handleProofStatusChange}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<ClipboardCheck className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-semibold">No detections found.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
There are no {defectClass} issues available for review.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defectLabel = activeDetection.detection.display_name;
|
||||
return (
|
||||
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<IssueReviewHeader
|
||||
ticketLabel={ticket.ticket_name || ticket.id}
|
||||
defectLabel={defectLabel}
|
||||
currentIndex={activeIndex}
|
||||
total={totalCount}
|
||||
proofStatus={proofStatus}
|
||||
onBack={backToTicket}
|
||||
onPrevious={() =>
|
||||
selectIssue(getIssueId(detections[Math.max(0, activeIndex - 1)]))
|
||||
}
|
||||
onNext={() => void handleNext()}
|
||||
onProofStatusChange={handleProofStatusChange}
|
||||
/>
|
||||
|
||||
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[280px_minmax(0,1fr)_330px] xl:items-stretch xl:overflow-hidden">
|
||||
<IssueReviewQueue
|
||||
detections={detections}
|
||||
totalCount={totalCount}
|
||||
selectedIssueId={activeIssueId}
|
||||
hasNextPage={Boolean(detectionsQuery.hasNextPage)}
|
||||
isFetchingNextPage={detectionsQuery.isFetchingNextPage}
|
||||
onSelect={selectIssue}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||
<IssueEvidencePanel
|
||||
detection={activeDetection}
|
||||
issueNumber={activeIndex + 1}
|
||||
videoWidth={detectionResult.video_width}
|
||||
videoHeight={detectionResult.video_height}
|
||||
reviewStatus={activeReviewStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<ClipboardCheck className="mx-auto size-7 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-semibold">View-only access</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Review permission is required to take action.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<IssueReviewAction
|
||||
detection={activeDetection}
|
||||
issueLabel={`${defectLabel} #${activeIndex + 1}`}
|
||||
status={activeReviewStatus}
|
||||
isPending={reviewMutation.isPending}
|
||||
onSave={handleSaveReview}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { DetectionDiscardReason, DiscardDetectionPayload } from '@/types';
|
||||
|
||||
const DISCARD_REASON_OPTIONS: Array<{
|
||||
value: DetectionDiscardReason;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'not_a_defect', label: 'Not a defect' },
|
||||
{ value: 'wrong_class', label: 'Wrong classification' },
|
||||
{ value: 'duplicate', label: 'Duplicate detection' },
|
||||
{ value: 'poor_image_quality', label: 'Poor image quality' },
|
||||
{ value: 'incorrect_location', label: 'Incorrect location' },
|
||||
{ value: 'already_repaired', label: 'Already repaired' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
interface DetectionDiscardDialogProps {
|
||||
open: boolean;
|
||||
detectionLabel: string;
|
||||
isPending: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: DiscardDetectionPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export function DetectionDiscardDialog({
|
||||
open,
|
||||
detectionLabel,
|
||||
isPending,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: DetectionDiscardDialogProps) {
|
||||
const [reason, setReason] = useState<DetectionDiscardReason | ''>('');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const resetForm = () => {
|
||||
setReason('');
|
||||
setComment('');
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (isPending) return;
|
||||
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!reason || isPending) return;
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
reason,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
resetForm();
|
||||
} catch {
|
||||
// The mutation displays the request error and keeps the dialog open.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent showCloseButton={!isPending}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Discard detection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove {detectionLabel} from this ticket and record why the AI
|
||||
result is incorrect.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discard-reason">Reason</Label>
|
||||
<Select
|
||||
value={reason}
|
||||
onValueChange={(value) =>
|
||||
setReason(value as DetectionDiscardReason)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger id="discard-reason" className="w-full">
|
||||
<SelectValue placeholder="Select a reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISCARD_REASON_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="discard-comment">Comment</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Optional | {comment.length}/500
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="discard-comment"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Add details that can help improve future detections"
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={!reason || isPending}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
||||
{isPending ? 'Discarding' : 'Discard detection'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +1,71 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||
|
||||
function TimelineEntrySkeleton({
|
||||
index,
|
||||
isLast,
|
||||
}: {
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TimelineItem
|
||||
layout="left"
|
||||
isLast={isLast}
|
||||
opposite={
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="ml-auto h-4 w-16" />
|
||||
<Skeleton className="ml-auto h-3 w-12" />
|
||||
</div>
|
||||
}
|
||||
marker={<Skeleton className="size-4 rounded-full" />}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
index === 0
|
||||
? 'rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5'
|
||||
: 'rounded-lg border bg-card p-4'
|
||||
}
|
||||
>
|
||||
{index === 0 ? <Skeleton className="h-5 w-28 rounded-lg" /> : null}
|
||||
<Skeleton className={index === 0 ? 'mt-3 h-4 w-44' : 'h-4 w-44'} />
|
||||
{index === 0 ? (
|
||||
<Skeleton className="mt-2 h-3 w-full max-w-md" />
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-3 w-48 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-16 w-full rounded-lg" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketClassContentSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5" 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="space-y-4" aria-label="Loading defect class details">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-5 w-56" />
|
||||
<Skeleton className="size-5 rounded-lg" />
|
||||
<Skeleton className="h-5 w-56 max-w-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
<Timeline>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="relative flex gap-4 pb-8 last:pb-0">
|
||||
{index < 2 ? (
|
||||
<span className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" />
|
||||
) : null}
|
||||
<Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Skeleton className="h-4 w-44" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-56" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<TimelineEntrySkeleton
|
||||
key={index}
|
||||
index={index}
|
||||
isLast={index === 2}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Timeline>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +75,7 @@ export function TicketClassActionsSkeleton() {
|
||||
<Card aria-label="Loading defect class actions">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="size-4 rounded-lg" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
@@ -11,9 +11,14 @@ import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage'
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries';
|
||||
import {
|
||||
useDiscardDetectionMutation,
|
||||
useTicketClassDetectionsQuery,
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||
|
||||
interface TicketClassDetectionPreviewProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
defectClassName: string;
|
||||
displayName: string;
|
||||
@@ -89,12 +94,14 @@ function TicketClassDetectionPreviewSkeleton() {
|
||||
}
|
||||
|
||||
export function TicketClassDetectionPreview({
|
||||
ticketId,
|
||||
videoId,
|
||||
defectClassName,
|
||||
displayName,
|
||||
totalCount,
|
||||
}: TicketClassDetectionPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
const visual = getDefectVisual(defectClassName);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
@@ -116,15 +123,14 @@ export function TicketClassDetectionPreview({
|
||||
videoId ?? undefined,
|
||||
queryParams,
|
||||
);
|
||||
const discardMutation = useDiscardDetectionMutation(
|
||||
ticketId,
|
||||
videoId ?? undefined,
|
||||
defectClassName,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data;
|
||||
const activeDetection = detectionResult?.items[0];
|
||||
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
|
||||
const mediaAspectRatio =
|
||||
detectionResult &&
|
||||
detectionResult.video_width > 0 &&
|
||||
detectionResult.video_height > 0
|
||||
? `${detectionResult.video_width} / ${detectionResult.video_height}`
|
||||
: '16 / 9';
|
||||
|
||||
useEffect(() => {
|
||||
if (detectionCount === 0 && currentIndex !== 0) {
|
||||
@@ -137,11 +143,8 @@ export function TicketClassDetectionPreview({
|
||||
}
|
||||
}, [currentIndex, detectionCount]);
|
||||
|
||||
const isPreviousDisabled = currentIndex === 0 || detectionsQuery.isLoading;
|
||||
const isNextDisabled =
|
||||
detectionCount === 0 ||
|
||||
currentIndex + 1 >= detectionCount ||
|
||||
detectionsQuery.isLoading;
|
||||
const isNavigationDisabled =
|
||||
detectionCount === 0 || detectionsQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -149,7 +152,7 @@ export function TicketClassDetectionPreview({
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
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,
|
||||
)}
|
||||
>
|
||||
@@ -157,13 +160,11 @@ export function TicketClassDetectionPreview({
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-lg font-semibold text-foreground">
|
||||
{displayName}
|
||||
</h3>
|
||||
<h3>{displayName}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'rounded-full border-0 px-2.5 py-1 text-xs font-semibold',
|
||||
'rounded-lg',
|
||||
visual.cardClassName,
|
||||
visual.colorClassName,
|
||||
)}
|
||||
@@ -171,7 +172,7 @@ export function TicketClassDetectionPreview({
|
||||
{detectionCount} Detections
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground">
|
||||
Review detections in ascending timestamp order.
|
||||
</p>
|
||||
</div>
|
||||
@@ -180,10 +181,15 @@ export function TicketClassDetectionPreview({
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentIndex((index) => Math.max(index - 1, 0))}
|
||||
disabled={isPreviousDisabled}
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setCurrentIndex((index) =>
|
||||
detectionCount === 0
|
||||
? 0
|
||||
: (index - 1 + detectionCount) % detectionCount,
|
||||
)
|
||||
}
|
||||
disabled={isNavigationDisabled}
|
||||
aria-label="Previous detection"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
@@ -195,10 +201,13 @@ export function TicketClassDetectionPreview({
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentIndex((index) => index + 1)}
|
||||
disabled={isNextDisabled}
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setCurrentIndex((index) =>
|
||||
detectionCount === 0 ? 0 : (index + 1) % detectionCount,
|
||||
)
|
||||
}
|
||||
disabled={isNavigationDisabled}
|
||||
aria-label="Next detection"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
@@ -233,14 +242,14 @@ export function TicketClassDetectionPreview({
|
||||
<>
|
||||
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Detection Image
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg">
|
||||
<p className="text-muted-foreground">Detection Image</p>
|
||||
<div className="w-full overflow-hidden rounded-lg">
|
||||
<AnnotatedDetectionImage
|
||||
detection={activeDetection}
|
||||
videoWidth={detectionResult.video_width}
|
||||
videoHeight={detectionResult.video_height}
|
||||
aspectRatio="16 / 9"
|
||||
enableLightbox
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,7 +261,7 @@ export function TicketClassDetectionPreview({
|
||||
label={`${displayName} - Frame ${activeDetection.frame.number}`}
|
||||
title="Location on Map"
|
||||
variant="plain"
|
||||
aspectRatio={mediaAspectRatio}
|
||||
aspectRatio="16 / 9"
|
||||
showCoordinates={false}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -75,9 +75,9 @@ export function TicketDefectClassTabs({
|
||||
|
||||
if (defectClassesQuery.isLoading) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Card className="w-full rounded-lg">
|
||||
<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">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
@@ -104,7 +104,7 @@ export function TicketDefectClassTabs({
|
||||
>
|
||||
<Card className="w-full">
|
||||
<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">
|
||||
<ListChecks className="size-5 shrink-0 text-primary" />
|
||||
<h2 className="text-base font-semibold">Detected Issues</h2>
|
||||
@@ -126,7 +126,7 @@ export function TicketDefectClassTabs({
|
||||
key={item.class_name}
|
||||
value={item.class_name}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
@@ -135,7 +135,7 @@ export function TicketDefectClassTabs({
|
||||
</span>
|
||||
<Badge
|
||||
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,
|
||||
)}
|
||||
>
|
||||
@@ -148,8 +148,9 @@ export function TicketDefectClassTabs({
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 p-5">
|
||||
<div className="min-w-0 px-5">
|
||||
<TicketClassDetectionPreview
|
||||
ticketId={ticketId}
|
||||
videoId={previewVideoId}
|
||||
defectClassName={selectedClass}
|
||||
displayName={previewDisplayName}
|
||||
|
||||
@@ -23,8 +23,7 @@ export function TicketDetailHeader({
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/ticket')}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
|
||||
@@ -48,10 +48,10 @@ function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
|
||||
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
|
||||
|
||||
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)}
|
||||
</Badge>
|
||||
@@ -70,7 +70,7 @@ function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
const isAssigned = item.event_type === 'assigned';
|
||||
|
||||
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>
|
||||
<PersonInfo person={item.actor} />
|
||||
{isAssigned && item.target_user ? (
|
||||
@@ -124,7 +124,7 @@ export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||
))}
|
||||
</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">
|
||||
No timeline entries yet
|
||||
</p>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MapPin,
|
||||
Monitor,
|
||||
UserRound,
|
||||
Play
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
@@ -77,7 +79,7 @@ function SummaryCard({
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
@@ -180,12 +182,11 @@ export function TicketOverviewCard({
|
||||
<CardAction>
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-primary hover:bg-primary/10 hover:text-primary"
|
||||
variant="secondary"
|
||||
onClick={() => setIsAnalysisOpen(true)}
|
||||
>
|
||||
View Analysis
|
||||
<Play className="mr-2 size-4" />
|
||||
View Video
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
</CardAction>
|
||||
@@ -232,16 +233,16 @@ export function TicketOverviewCard({
|
||||
</Card>
|
||||
|
||||
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
|
||||
<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">
|
||||
{analysisTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="truncate text-xs">
|
||||
<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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
function OverviewSkeletonField() {
|
||||
function OverviewMetaSkeletonField({ className }: { className?: string }) {
|
||||
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-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>
|
||||
);
|
||||
}
|
||||
@@ -27,15 +45,45 @@ export function TicketOverviewHeaderSkeleton() {
|
||||
|
||||
export function TicketOverviewCardSkeleton() {
|
||||
return (
|
||||
<Card aria-label="Loading ticket overview">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<OverviewSkeletonField key={index} />
|
||||
))}
|
||||
<div className="space-y-5" aria-label="Loading ticket overview">
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||
<OverviewMetaSkeletonField className="sm:pr-4" />
|
||||
<OverviewMetaSkeletonField className="sm:px-4" />
|
||||
<OverviewMetaSkeletonField className="sm:pl-4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-lg" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</div>
|
||||
<CardAction>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<SummarySkeletonCard key={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<Skeleton className="size-4 rounded-lg" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { AssignedContractorAction } from './actions/AssignedContractorAction';
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||
import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
|
||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||
|
||||
interface TicketStatusActionsProps {
|
||||
ticket: TicketDetail;
|
||||
@@ -37,11 +37,14 @@ function getTicketAction(ticket: TicketDetail) {
|
||||
permissions={PERMISSIONS.TICKET.WORK}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<SubmitRepairAction ticket={ticket} />
|
||||
<RequestExtensionAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
<ReviewExtensionRequestAction ticket={ticket} />
|
||||
</>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +55,7 @@ function getTicketAction(ticket: TicketDetail) {
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<ReviewRepairAction ticket={ticket} />
|
||||
<OpenRepairReviewAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
@@ -68,15 +71,5 @@ function getTicketAction(ticket: TicketDetail) {
|
||||
}
|
||||
|
||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||
const action = getTicketAction(ticket);
|
||||
const submittedProof = ticket.repair?.submitted_at ? (
|
||||
<SubmittedRepairProofAction ticket={ticket} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{submittedProof}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
return <div className="space-y-3">{getTicketAction(ticket)}</div>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from '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 { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -21,6 +21,15 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
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 (
|
||||
<TicketActionCard icon={Users} title="Assign to Contractor">
|
||||
@@ -56,13 +65,20 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label>Due Date *</Label>
|
||||
<DatePicker
|
||||
<Label htmlFor="assign-due-date">
|
||||
Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePickerSimple
|
||||
id="assign-due-date"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
showTimeZone={false}
|
||||
showLabel={false}
|
||||
placeholder="Select due date"
|
||||
disabled={assignMutation.isPending}
|
||||
className="w-full"
|
||||
startMonth={today}
|
||||
endMonth={maxDueMonth}
|
||||
disabledDates={{ before: today }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarClock, UserCheck } from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
function AssignmentField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="truncate text-sm font-medium text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssignedContractorAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const worker = ticket.worker;
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={UserCheck} title="Assigned Contractor">
|
||||
{worker ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<PersonInfo person={worker} size="lg" />
|
||||
<Badge>
|
||||
Assigned
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-5 border-t pt-4">
|
||||
<AssignmentField label="Role" value={worker.role?.name ?? '-'} />
|
||||
<AssignmentField
|
||||
label="Assigned By"
|
||||
value={worker.assigned_by_name ?? '-'}
|
||||
/>
|
||||
<AssignmentField
|
||||
label="Assigned On"
|
||||
value={formatDate(worker.assigned_at)}
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Due Date</p>
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<CalendarClock className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{formatDate(worker.due_at)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Assignment details are not available.
|
||||
</p>
|
||||
)}
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
</CardHeader>
|
||||
|
||||
<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
|
||||
label="Reviewed By"
|
||||
value={<PersonInfo person={reviewer} size="lg" />}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowRight, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={ClipboardCheck} title="Review Submitted Repairs">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Review every submitted repair separately using its detection image,
|
||||
location, and repair proof.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class),
|
||||
)
|
||||
}
|
||||
>
|
||||
Open Issue Review
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Camera,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
Send,
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||
|
||||
const MAX_IMAGES = 30;
|
||||
const MAX_NOTES = 500;
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive">*</span>;
|
||||
}
|
||||
|
||||
function formatVideoDuration(seconds: number) {
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface MediaSectionHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
addLabel: string;
|
||||
onAdd: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MediaSectionHeader({
|
||||
icon,
|
||||
title,
|
||||
addLabel,
|
||||
onAdd,
|
||||
disabled = false,
|
||||
}: MediaSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{icon}</span>
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={onAdd}
|
||||
className="h-8 shrink-0 border-primary/30 text-primary hover:bg-primary/5 hover:text-primary"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaHint({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyMediaPlaceholderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function EmptyMediaPlaceholder({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: EmptyMediaPlaceholderProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/80 bg-muted/15 px-4 py-6 text-center transition-colors',
|
||||
'hover:border-primary/40 hover:bg-primary/5',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="max-w-xs text-xs text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImageThumbnailProps {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function ImageThumbnail({
|
||||
file,
|
||||
previewUrl,
|
||||
disabled = false,
|
||||
onRemove,
|
||||
}: ImageThumbnailProps) {
|
||||
return (
|
||||
<div className="group relative size-20 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoThumbnailProps {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
duration: string | null;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function VideoThumbnail({
|
||||
file,
|
||||
previewUrl,
|
||||
duration,
|
||||
disabled = false,
|
||||
onRemove,
|
||||
}: VideoThumbnailProps) {
|
||||
return (
|
||||
<div className="group relative h-20 w-36 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
||||
<video
|
||||
src={previewUrl}
|
||||
className="size-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
aria-label={file.name}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm">
|
||||
<Play className="size-3.5 fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
{duration ? (
|
||||
<span className="pointer-events-none absolute bottom-1.5 right-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
{duration}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RepairEvidenceFormProps {
|
||||
ticketId: string;
|
||||
defectClass: string;
|
||||
}
|
||||
|
||||
export function RepairEvidenceForm({
|
||||
ticketId,
|
||||
defectClass,
|
||||
}: RepairEvidenceFormProps) {
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [videoDuration, setVideoDuration] = useState<string | null>(null);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
||||
const isSubmitting = submitRepairUploadMutation.isPending;
|
||||
const canSubmit = Boolean(notes.trim() && videoFile);
|
||||
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
|
||||
|
||||
useEffect(() => {
|
||||
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
||||
setImagePreviews(urls);
|
||||
return () => {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [imageFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoFile) {
|
||||
setVideoPreview(null);
|
||||
setVideoDuration(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
setVideoDuration(null);
|
||||
|
||||
let cancelled = false;
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
if (cancelled || !Number.isFinite(video.duration)) return;
|
||||
setVideoDuration(formatVideoDuration(video.duration));
|
||||
};
|
||||
video.src = url;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [videoFile]);
|
||||
|
||||
const addImages = (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
|
||||
const remaining = MAX_IMAGES - imageFiles.length;
|
||||
if (remaining <= 0) {
|
||||
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextImages = [
|
||||
...imageFiles,
|
||||
...Array.from(files).slice(0, remaining),
|
||||
];
|
||||
setImageFiles(nextImages);
|
||||
setError(
|
||||
files.length > remaining
|
||||
? `Only ${MAX_IMAGES} images can be uploaded.`
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
const selectVideo = (files: FileList | null) => {
|
||||
setVideoFile(files?.[0] ?? null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const removeVideo = () => {
|
||||
setVideoFile(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImageFiles((current) =>
|
||||
current.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!notes.trim() || !videoFile) {
|
||||
setError('Repair notes and a video are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
await submitRepairUploadMutation.mutateAsync({
|
||||
defect_class: defectClass,
|
||||
notes: notes.trim(),
|
||||
video: videoFile,
|
||||
images: imageFiles,
|
||||
});
|
||||
setNotes('');
|
||||
setVideoFile(null);
|
||||
setImageFiles([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
const openImagePicker = () => {
|
||||
if (!isSubmitting && canAddMoreImages) {
|
||||
imageInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const openVideoPicker = () => {
|
||||
if (!isSubmitting) {
|
||||
videoInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
|
||||
<Camera className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">Instructions</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload clear photos and one video of the repaired area and provide
|
||||
relevant details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
disabled={isSubmitting || !canAddMoreImages}
|
||||
onChange={(event) => {
|
||||
addImages(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/x-msvideo,video/*"
|
||||
className="hidden"
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => {
|
||||
selectVideo(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="space-y-3">
|
||||
<MediaSectionHeader
|
||||
icon={<Camera className="size-4" />}
|
||||
title="Photos"
|
||||
addLabel="Add Photos"
|
||||
onAdd={openImagePicker}
|
||||
disabled={isSubmitting || !canAddMoreImages}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{imageFiles.length === 0 ? (
|
||||
<EmptyMediaPlaceholder
|
||||
icon={<ImageIcon className="size-5" />}
|
||||
title="No photos added yet"
|
||||
description="Tap to upload repair photos"
|
||||
disabled={isSubmitting}
|
||||
onClick={openImagePicker}
|
||||
className="min-h-[104px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-w-min gap-3">
|
||||
{imageFiles.map((file, index) => (
|
||||
<ImageThumbnail
|
||||
key={`${file.name}-${file.lastModified}-${index}`}
|
||||
file={file}
|
||||
previewUrl={imagePreviews[index]}
|
||||
disabled={isSubmitting}
|
||||
onRemove={() => removeImage(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaHint>
|
||||
You can upload up to {MAX_IMAGES} images (JPG, PNG)
|
||||
</MediaHint>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Video</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!videoFile || !videoPreview ? (
|
||||
<EmptyMediaPlaceholder
|
||||
icon={<Video className="size-5" />}
|
||||
title="No video added yet"
|
||||
description="Tap to upload a repair video"
|
||||
disabled={isSubmitting}
|
||||
onClick={openVideoPicker}
|
||||
className="min-h-[104px]"
|
||||
/>
|
||||
) : (
|
||||
<VideoThumbnail
|
||||
file={videoFile}
|
||||
previewUrl={videoPreview}
|
||||
duration={videoDuration}
|
||||
disabled={isSubmitting}
|
||||
onRemove={removeVideo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaHint>You can upload one video (MP4, MOV, AVI)</MediaHint>
|
||||
</section>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repair-notes">
|
||||
Repair Notes <RequiredMark />
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="repair-notes"
|
||||
value={notes}
|
||||
onChange={(event) => {
|
||||
setNotes(event.target.value.slice(0, MAX_NOTES));
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Describe the repair work completed, materials used, and any additional observations."
|
||||
disabled={isSubmitting}
|
||||
className="min-h-28 resize-none pb-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
|
||||
{notes.length} / {MAX_NOTES}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
Submit for Review
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Clock3 } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||
|
||||
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) {
|
||||
return (
|
||||
<TicketActionCard icon={Clock3} title="Request Extension">
|
||||
<TicketExtensionRequestPanel ticket={ticket} />
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Clock3, Loader2, Send } from 'lucide-react';
|
||||
|
||||
import { DatePicker } from '@/components/form/DatePicker';
|
||||
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -15,16 +16,24 @@ const MAX_REASON_LENGTH = 300;
|
||||
|
||||
interface RequestExtensionFormProps {
|
||||
ticket: TicketDetail;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function RequestExtensionForm({
|
||||
ticket,
|
||||
onCancel,
|
||||
}: RequestExtensionFormProps) {
|
||||
const currentDueDate = ticket.worker?.due_at
|
||||
? new Date(ticket.worker.due_at)
|
||||
: undefined;
|
||||
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
|
||||
const currentDueAt = ticket.worker?.due_at;
|
||||
const currentDueDate = useMemo(
|
||||
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
||||
[currentDueAt],
|
||||
);
|
||||
const earliestRequestedDate = useMemo(() => {
|
||||
const date = currentDueDate ? new Date(currentDueDate) : new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
if (currentDueDate) date.setDate(date.getDate() + 1);
|
||||
return date;
|
||||
}, [currentDueDate]);
|
||||
const latestRequestedMonth = useMemo(
|
||||
() => new Date(earliestRequestedDate.getFullYear() + 20, 11),
|
||||
[earliestRequestedDate],
|
||||
);
|
||||
const [requestedDueDate, setRequestedDueDate] = useState<Date>();
|
||||
const [reason, setReason] = useState('');
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
@@ -53,7 +62,6 @@ export function RequestExtensionForm({
|
||||
});
|
||||
setRequestedDueDate(undefined);
|
||||
setReason('');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -81,12 +89,17 @@ export function RequestExtensionForm({
|
||||
<Label>
|
||||
Requested Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
<DatePickerSimple
|
||||
id="requested-due-date"
|
||||
value={requestedDueDate}
|
||||
onChange={setRequestedDueDate}
|
||||
showTimeZone={false}
|
||||
showLabel={false}
|
||||
placeholder="Select requested due date"
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
startMonth={earliestRequestedDate}
|
||||
endMonth={latestRequestedMonth}
|
||||
disabledDates={{ before: earliestRequestedDate }}
|
||||
/>
|
||||
{requestedDueDate && !isRequestedDateValid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
@@ -120,20 +133,15 @@ export function RequestExtensionForm({
|
||||
This request will be sent to your manager for approval.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={onCancel}
|
||||
className="w-full"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
useReviewTicketExtensionMutation,
|
||||
useTicketExtensionRequestQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
import { NoTicketAction } from './NoTicketAction';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
const MAX_NOTE_LENGTH = 300;
|
||||
@@ -208,7 +207,7 @@ export function ReviewExtensionRequestAction({
|
||||
);
|
||||
|
||||
if (!assignmentId || extensionRequestQuery.isError) {
|
||||
return <NoTicketAction />;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (extensionRequestQuery.isLoading) {
|
||||
@@ -226,7 +225,7 @@ export function ReviewExtensionRequestAction({
|
||||
? getPendingRequest(extensionRequestQuery.data)
|
||||
: null;
|
||||
|
||||
if (!pendingRequest) return <NoTicketAction />;
|
||||
if (!pendingRequest) return null;
|
||||
|
||||
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ClipboardCheck, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [reviewComment, setReviewComment] = useState('');
|
||||
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
|
||||
|
||||
useEffect(() => {
|
||||
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
||||
}, [ticket]);
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Comment</Label>
|
||||
<Textarea
|
||||
value={reviewComment}
|
||||
onChange={(event) => setReviewComment(event.target.value)}
|
||||
placeholder="Repair verified"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewRepairMutation.mutate({
|
||||
defect_class: ticket.defect_class,
|
||||
action: 'approve',
|
||||
comment: reviewComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Check />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewRepairMutation.mutate({
|
||||
defect_class: ticket.defect_class,
|
||||
action: 'reject',
|
||||
comment: reviewComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
<X />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';
|
||||
@@ -22,7 +22,7 @@ export function TicketActionCard({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Icon className={cn('size-4 text-primary', iconClassName)} />
|
||||
<Icon className={cn('text-primary', iconClassName)} />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -23,11 +23,6 @@ import { formatDate } from '@/utils/date';
|
||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||
import { RequestExtensionForm } from './RequestExtensionForm';
|
||||
|
||||
interface TicketExtensionRequestPanelProps {
|
||||
ticket: TicketDetail;
|
||||
onCancelRequest: () => void;
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -192,8 +187,9 @@ function ExtensionRequestDetails({
|
||||
|
||||
export function TicketExtensionRequestPanel({
|
||||
ticket,
|
||||
onCancelRequest,
|
||||
}: TicketExtensionRequestPanelProps) {
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
}) {
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
@@ -241,7 +237,7 @@ export function TicketExtensionRequestPanel({
|
||||
: null;
|
||||
|
||||
if (!latestRequest) {
|
||||
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
|
||||
return <RequestExtensionForm ticket={ticket} />;
|
||||
}
|
||||
|
||||
return <ExtensionRequestDetails request={latestRequest} />;
|
||||
|
||||
@@ -11,11 +11,15 @@ import { protectedMediaService } from '@/services/api';
|
||||
|
||||
interface RepairProofGalleryProps {
|
||||
imageUrls: string[];
|
||||
maxVisibleImages?: number;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_IMAGES = 8;
|
||||
|
||||
export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||
export function RepairProofGallery({
|
||||
imageUrls,
|
||||
maxVisibleImages = MAX_VISIBLE_IMAGES,
|
||||
}: RepairProofGalleryProps) {
|
||||
const queries = useQueries({
|
||||
queries: imageUrls.map((url) => ({
|
||||
queryKey: ['protected-media', 'v2', url],
|
||||
@@ -43,11 +47,11 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||
|
||||
if (queries.some((query) => query.isLoading)) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-xl">
|
||||
{imageUrls.slice(0, MAX_VISIBLE_IMAGES).map((url, index) => (
|
||||
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-lg">
|
||||
{imageUrls.slice(0, maxVisibleImages).map((url, index) => (
|
||||
<Skeleton
|
||||
key={`${url}-${index}`}
|
||||
className="aspect-square rounded-none"
|
||||
className="aspect-square rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -57,7 +61,7 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||
const images = objectUrls.flatMap((url, 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;
|
||||
|
||||
if (images.length === 0) {
|
||||
@@ -70,7 +74,7 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||
|
||||
return (
|
||||
<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"
|
||||
speed={400}
|
||||
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"
|
||||
/>
|
||||
{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">
|
||||
+{overflowCount}
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/55 px-2 text-center text-sm font-semibold text-white transition-colors group-hover:bg-black/65 sm:text-base">
|
||||
+{overflowCount} more
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -58,15 +58,17 @@ export default function TicketDetailPage() {
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<TicketDetailHeader ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
|
||||
<div className="space-y-5">
|
||||
<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 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{overview ? (
|
||||
<TicketOverviewCard ticket={overview} />
|
||||
) : (
|
||||
@@ -79,7 +81,7 @@ export default function TicketDetailPage() {
|
||||
) : null}
|
||||
</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 && classDetail ? (
|
||||
<TicketStatusActions ticket={classDetail} />
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
'use client';
|
||||
|
||||
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 { ticketService, videoService } from '@/services/api';
|
||||
import { detectionService, ticketService, videoService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
CloseTicketPayload,
|
||||
DiscardDetectionPayload,
|
||||
RequestTicketExtensionPayload,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
ReviewTicketExtensionPayload,
|
||||
ReviewRepairPayload,
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
TicketDetail,
|
||||
TicketListParams,
|
||||
DetectionProofStatus,
|
||||
VideoDetectionsParams,
|
||||
} 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(
|
||||
ticketId: string | 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(
|
||||
ticketId: string,
|
||||
assignmentId: number | undefined,
|
||||
@@ -265,26 +352,6 @@ export function useReviewTicketExtensionMutation(
|
||||
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) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -9,8 +9,21 @@ export const ticketKeys = {
|
||||
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
||||
classDetail: (ticketId: string, defectClass: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
||||
classDetectionLists: (videoId: string) =>
|
||||
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||
[...ticketKeys.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) =>
|
||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Loader2, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -89,19 +90,17 @@ export function CreateUploadDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<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()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
Upload Video
|
||||
</DialogTitle>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Video</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure location and upload road inspection data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
<DialogBody className="space-y-5">
|
||||
<LocationSection
|
||||
value={session}
|
||||
onChange={setSession}
|
||||
@@ -130,13 +129,13 @@ export function CreateUploadDialog({
|
||||
portalContainer={dropdownPortalRef}
|
||||
/>
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
Ticket,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface UploadActionsMenuProps {
|
||||
upload: UploadListItem;
|
||||
onViewVideo: (upload: UploadListItem) => void;
|
||||
}
|
||||
|
||||
export function UploadActionsMenu({
|
||||
upload,
|
||||
onViewVideo,
|
||||
}: UploadActionsMenuProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
<span className="sr-only">Open actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{upload.raw_video_url ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onViewVideo(upload);
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="size-4" />
|
||||
View Video
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem>
|
||||
<Eye /> View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<RotateCcw /> Retry Analysis
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!upload.ticket_id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (upload.ticket_id) {
|
||||
router.push(`/ticket/${upload.ticket_id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ticket /> Open Ticket
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2 /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ExternalLink, Ticket, UserRound } from 'lucide-react';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
interface UploadDetailsDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UploadDetailsDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UploadDetailsDialogProps) {
|
||||
const { hasPermission } = usePermissions();
|
||||
const canOpenTicket = hasPermission(PERMISSIONS.TICKET.READ);
|
||||
const details = [
|
||||
{ label: 'Project', value: upload?.project },
|
||||
{ label: 'Package', value: upload?.package },
|
||||
{ label: 'Segment', value: upload?.segment },
|
||||
{ label: 'Chainage', value: upload?.chainage },
|
||||
{ label: 'Uploaded', value: formatDate(upload?.uploaded_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preview the inspection video and review its processing details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,0.8fr)]">
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="text-sm font-medium">Video Preview</p>
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
label={upload?.video_name}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="min-w-0 space-y-4 rounded-lg border bg-muted/15 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<div className="mt-1.5">
|
||||
{upload ? (
|
||||
<UploadStatusBadge
|
||||
status={upload.status}
|
||||
progress={upload.progress}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Detections</p>
|
||||
<Badge variant="secondary" className="mt-1.5 tabular-nums">
|
||||
{upload?.total_detections ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="mb-2 text-xs text-muted-foreground">Result</p>
|
||||
{upload ? <UploadResultCell item={upload} /> : null}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-t pt-4">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
key={detail.label}
|
||||
className={
|
||||
detail.label === 'Uploaded' ? 'col-span-2' : undefined
|
||||
}
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{detail.label}
|
||||
</dt>
|
||||
<dd className="mt-1 truncate text-sm font-medium">
|
||||
{detail.value || '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex min-w-0 items-start gap-2 border-t pt-4">
|
||||
<UserRound className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{upload?.uploaded_by.name || 'Unknown uploader'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{upload?.uploaded_by.email || 'No email available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter showCloseButton>
|
||||
{upload?.ticket_id && canOpenTicket ? (
|
||||
<Button asChild>
|
||||
<Link href={ROUTES.TICKET_DETAIL(upload.ticket_id)}>
|
||||
<Ticket />
|
||||
Open Ticket
|
||||
<ExternalLink />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function UploadFileDropzone({
|
||||
handleFiles(event.dataTransfer.files);
|
||||
}}
|
||||
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',
|
||||
isDragging && 'border-primary bg-secondary/60',
|
||||
disabled && 'pointer-events-none cursor-not-allowed opacity-60',
|
||||
@@ -72,7 +72,7 @@ export function UploadFileDropzone({
|
||||
/>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadActionsMenu } from './UploadActionsMenu';
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
@@ -22,9 +21,7 @@ function resolveThumbnailUrl(value: string | null) {
|
||||
return baseUrl ? `${baseUrl}${path}` : path;
|
||||
}
|
||||
|
||||
export function useUploadListColumns(
|
||||
onViewVideo: (upload: UploadListItem) => void,
|
||||
): ColumnDef<UploadListItem>[] {
|
||||
export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -39,7 +36,7 @@ export function useUploadListColumns(
|
||||
|
||||
return (
|
||||
<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={
|
||||
thumbnail
|
||||
? { backgroundImage: `url("${thumbnail}")` }
|
||||
@@ -133,24 +130,7 @@ export function useUploadListColumns(
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <UploadResultCell item={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<UploadActionsMenu
|
||||
upload={row.original}
|
||||
onViewVideo={onViewVideo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onViewVideo],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UploadTableProps {
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onUploadClick: (upload: UploadListItem) => void;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export function UploadTable({
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onUploadClick,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: UploadTableProps) {
|
||||
@@ -33,6 +35,7 @@ export function UploadTable({
|
||||
isLoading={isLoading}
|
||||
emptyTitle="No uploads found"
|
||||
emptyDescription="Upload your first road inspection video."
|
||||
onRowClick={onUploadClick}
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface UploadVideoDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UploadVideoDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UploadVideoDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-3 p-3 sm:max-w-5xl sm:p-4">
|
||||
<DialogHeader className="min-w-0 pr-8">
|
||||
<DialogTitle className="truncate text-left text-base">
|
||||
Video Preview
|
||||
</DialogTitle>
|
||||
<DialogDescription className="truncate text-xs">
|
||||
{upload?.video_name ?? 'Uploaded video'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { UploadListItem } from '@/types';
|
||||
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
||||
import { useUploadListColumns } from './components/UploadListColumns';
|
||||
import { UploadTable } from './components/UploadTable';
|
||||
import { UploadVideoDialog } from './components/UploadVideoDialog';
|
||||
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
|
||||
import { useUploadFilters } from './hooks/useUploadFilters';
|
||||
import { useUploadListEvents } from './hooks/useUploadListEvents';
|
||||
import { useUploadsQuery } from './hooks/useUploadQueries';
|
||||
@@ -20,13 +20,15 @@ export default function UploadPage() {
|
||||
const uploadsQuery = useUploadsQuery({ skip, limit });
|
||||
useUploadListEvents();
|
||||
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 total = uploadsQuery.data?.total ?? 0;
|
||||
const isLoading = uploadsQuery.isLoading;
|
||||
|
||||
const columns = useUploadListColumns(setVideoUpload);
|
||||
const columns = useUploadListColumns();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,17 +52,18 @@ export default function UploadPage() {
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onUploadClick={setSelectedUpload}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||
<UploadVideoDialog
|
||||
upload={videoUpload}
|
||||
open={Boolean(videoUpload)}
|
||||
<UploadDetailsDialog
|
||||
upload={selectedUpload}
|
||||
open={Boolean(selectedUpload)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVideoUpload(null);
|
||||
if (!open) setSelectedUpload(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { RoleSelect } from '@/components/lookups/RoleSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -67,13 +68,13 @@ export function UserSheet({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{userId ? 'Edit User' : 'Create User'}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Assign user details and a role.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -83,7 +84,7 @@ export function UserSheet({
|
||||
onSubmit={onSubmit}
|
||||
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">
|
||||
<FormField
|
||||
id="first-name"
|
||||
@@ -172,9 +173,9 @@ export function UserSheet({
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,84 +1,86 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import './typography.css';
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--radius: 0.225rem;
|
||||
--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-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.541 0.281 293.009);
|
||||
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.457 0.24 277.023);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.702 0.183 293.541);
|
||||
--chart-1: oklch(0.65 0.18 25); /* Soft Red/Rose - High Priority */
|
||||
--chart-2: oklch(0.78 0.12 75); /* Warm Amber - Distinct */
|
||||
--chart-3: oklch(0.68 0.12 245); /* Azure Blue - Cool Professional */
|
||||
--chart-4: oklch(0.75 0.1 165); /* Mint Teal - Balanced */
|
||||
--chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.785 0.115 274.713); /* Soft Red/Rose - High Priority */
|
||||
--chart-2: oklch(0.585 0.233 277.117); /* Warm Amber - Distinct */
|
||||
--chart-3: oklch(0.511 0.262 276.966); /* Azure Blue - Cool Professional */
|
||||
--chart-4: oklch(0.457 0.24 277.023); /* Mint Teal - Balanced */
|
||||
--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-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.541 0.281 293.009);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.702 0.183 293.541);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.511 0.262 276.966);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--background: oklch(0.145 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);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.606 0.25 292.717);
|
||||
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--primary: oklch(0.398 0.195 277.366);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.38 0.189 293.745);
|
||||
--chart-1: oklch(0.7 0.16 25);
|
||||
--chart-2: oklch(0.82 0.14 75);
|
||||
--chart-3: oklch(0.72 0.14 245);
|
||||
--chart-4: oklch(0.78 0.12 165);
|
||||
--chart-5: oklch(0.88 0.1 195);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.785 0.115 274.713);
|
||||
--chart-2: oklch(0.585 0.233 277.117);
|
||||
--chart-3: oklch(0.511 0.262 276.966);
|
||||
--chart-4: oklch(0.457 0.24 277.023);
|
||||
--chart-5: oklch(0.398 0.195 277.366);
|
||||
--chart-6: oklch(0.65 0.24 295);
|
||||
--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-primary: oklch(0.606 0.25 292.717);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--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 {
|
||||
--font-sans: 'Poppins', 'Poppins Fallback', system-ui, sans-serif;
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: 'Geist Mono', 'Geist Mono Fallback', monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -119,6 +121,10 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--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 {
|
||||
@@ -133,7 +139,7 @@
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-none;
|
||||
@apply outline-none border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -143,6 +149,12 @@
|
||||
.scroll-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
button:not(:disabled), [role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.detection-video-player .vds-slider-preview {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist_Mono, Poppins } from 'next/font/google';
|
||||
import { Geist_Mono, Poppins, Inter } from 'next/font/google';
|
||||
import '@vidstack/react/player/styles/default/theme.css';
|
||||
import '@vidstack/react/player/styles/default/layouts/video.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
@@ -11,6 +11,10 @@ import { ThemeProvider } from '@/providers/ThemeProvider';
|
||||
import AppInitializer from '@/providers/AppInitializer';
|
||||
import QueryProvider from '@/providers/QueryProvider';
|
||||
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({
|
||||
variable: '--font-poppins',
|
||||
@@ -37,7 +41,11 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn('font-sans', "font-sans", inter.variable)}
|
||||
>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/flags/flags.css" />
|
||||
</head>
|
||||
@@ -50,8 +58,10 @@ export default function RootLayout({
|
||||
>
|
||||
<QueryProvider>
|
||||
<AppInitializer>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{children}
|
||||
<Toaster position="top-center" />
|
||||
</TooltipProvider>
|
||||
</AppInitializer>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'size-8 rounded-md border-2 p-0.5',
|
||||
md: 'size-12 rounded-md border-2 p-1.5',
|
||||
lg: 'size-28 rounded-lg border-[5px] p-3',
|
||||
} as const;
|
||||
|
||||
const variantClasses = {
|
||||
default: 'border-white bg-primary shadow-md',
|
||||
solid: 'border-white bg-primary',
|
||||
glass: 'border-primary-foreground bg-primary/85 shadow-md',
|
||||
sidebar: 'border-0 bg-primary',
|
||||
} as const;
|
||||
|
||||
type LogoWrapperProps = {
|
||||
alt?: string;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
priority?: boolean;
|
||||
size?: keyof typeof sizeClasses;
|
||||
variant?: keyof typeof variantClasses;
|
||||
};
|
||||
|
||||
export function LogoWrapper({
|
||||
alt = 'RoadMonitor logo',
|
||||
className,
|
||||
imageClassName,
|
||||
priority = false,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
}: LogoWrapperProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt={alt}
|
||||
width={112}
|
||||
height={112}
|
||||
className={cn(
|
||||
'size-full object-contain brightness-0 invert',
|
||||
imageClassName,
|
||||
)}
|
||||
priority={priority}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -27,7 +28,9 @@ export function BreadcrumbBasic() {
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{segments.length > 0 && <BreadcrumbSeparator />}
|
||||
|
||||
@@ -44,8 +47,8 @@ export function BreadcrumbBasic() {
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink href={href}>
|
||||
{formatSegment(segment)}
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={href}>{formatSegment(segment)}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { LogoWrapper } from '@/components/LogoWrapper';
|
||||
import Image from 'next/image';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
filterMenuItems,
|
||||
@@ -18,17 +18,23 @@ import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
@@ -41,6 +47,7 @@ function isRouteActive(pathname: string, path?: string) {
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname();
|
||||
const { hasPermission } = usePermissions();
|
||||
const { isMobile } = useSidebar();
|
||||
const authUser = useAppStore((state) => state.user);
|
||||
const navItems = filterMenuItems(menuItems, hasPermission);
|
||||
const user = {
|
||||
@@ -48,22 +55,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
[authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') ||
|
||||
'Admin',
|
||||
email: authUser?.email || '',
|
||||
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<LogoWrapper size="sm" variant="sidebar" />
|
||||
<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 h-8 w-full items-center gap-3 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center">
|
||||
<SidebarBrandControl isMobile={isMobile} />
|
||||
<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 text-xs">Road Intelligence</span>
|
||||
</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>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarContent className="px-2 py-3">
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
@@ -75,7 +85,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarFooter className="border-t border-sidebar-border px-3 py-3">
|
||||
<NavUser user={user} />
|
||||
</SidebarFooter>
|
||||
<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({
|
||||
item,
|
||||
pathname,
|
||||
@@ -159,4 +193,3 @@ function SidebarNavItem({
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LogoWrapper } from '@/components/LogoWrapper';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
|
||||
const BOKEH_ORBS = [
|
||||
'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">
|
||||
<div className="mb-9 inline-flex items-center gap-5 text-left">
|
||||
<LogoWrapper
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
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
|
||||
variant="glass"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-5xl font-semibold leading-none tracking-normal">
|
||||
Road
|
||||
</span>
|
||||
<span className="block text-5xl font-semibold leading-none tracking-normal">
|
||||
Monitor
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mx-auto max-w-md text-base leading-7 text-primary-foreground/82">
|
||||
|
||||
<div className="absolute bottom-10 left-10 right-10 text-left text-primary-foreground">
|
||||
<p className="text-3xl leading-none tracking-tight">
|
||||
<span className="font-semibold">Road</span>
|
||||
<span className="font-normal">Monitor</span>
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-xs leading-4 text-primary-foreground/90">
|
||||
Map, monitor, and repair road issues with data-driven surface
|
||||
intelligence.
|
||||
</p>
|
||||
@@ -107,4 +104,3 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
@@ -25,26 +30,33 @@ export function TableFooter<TData>({
|
||||
pageSize?: number;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
}) {
|
||||
const rows = table.getRowModel().rows.length;
|
||||
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 (
|
||||
<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-wrap items-center gap-3">
|
||||
<span>
|
||||
Showing {rows} of {totalItems}
|
||||
</span>
|
||||
<div className="flex flex-col gap-3 px-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
{selectedRowCount} of {totalItems} row(s) selected.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 sm:justify-end">
|
||||
{onPageSizeChange ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Rows per page</span>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<span className="font-medium">Rows per page</span>
|
||||
<Select
|
||||
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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent align="end">
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
@@ -54,26 +66,57 @@ export function TableFooter<TData>({
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-24 text-foreground font-medium">
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
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()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon-sm"
|
||||
className="size-8 shadow-none"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="size-8 shadow-none"
|
||||
onClick={() => table.setPageIndex(pageCount - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<ChevronsRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,26 +21,35 @@ const TopHeader = ({
|
||||
tableTools,
|
||||
}: TopHeaderProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{(title || onAddNew) && (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
{title ? <h2>{title}</h2> : null}
|
||||
<p className="text-muted-foreground">Total {itemCount ?? 0}</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{title ? <h2 className="text-lg font-semibold">{title}</h2> : null}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total {itemCount ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
{onAddNew ? (
|
||||
<Button onClick={onAddNew}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
<Button onClick={onAddNew} size="sm" className="w-fit">
|
||||
<Plus className="size-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(toolbar || tableTools) && (
|
||||
<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 ? (
|
||||
<div className="flex shrink-0 justify-end">{tableTools}</div>
|
||||
<div className="flex shrink-0 justify-end **:data-[slot=button]:shadow-none">
|
||||
{tableTools}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { getPinnedColumnStyles } from './columnStyles';
|
||||
|
||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||
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) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
@@ -39,9 +39,9 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||
canSort ? header.column.getToggleSortingHandler() : undefined
|
||||
}
|
||||
className={cn(
|
||||
'h-10 max-w-0 overflow-hidden border-b border-border bg-card px-2 text-foreground transition-colors',
|
||||
canSort && 'cursor-pointer select-none hover:bg-muted/60',
|
||||
sortDirection && 'bg-muted/70',
|
||||
'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',
|
||||
sortDirection && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
@@ -70,7 +70,7 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
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',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -23,14 +23,20 @@ export function TableSearchInput({
|
||||
className,
|
||||
}: TableSearchInputProps) {
|
||||
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>
|
||||
<Search />
|
||||
<Search className="size-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import type { PermissionInput } from '@/hooks/usePermissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
|
||||
@@ -211,8 +210,8 @@ function DataTableContent<TData, TValue>({
|
||||
},
|
||||
manualPagination: !!pagination,
|
||||
manualSorting: isManualSorting,
|
||||
pageCount: pagination?.totalItems
|
||||
? Math.ceil(pagination.totalItems / pagination.limit)
|
||||
pageCount: pagination
|
||||
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
|
||||
: -1,
|
||||
state: {
|
||||
rowSelection,
|
||||
@@ -247,8 +246,8 @@ function DataTableContent<TData, TValue>({
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<section className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<>
|
||||
<section className="space-y-3">
|
||||
<TopHeader
|
||||
title={onAddNew ? title : undefined}
|
||||
itemCount={pagination?.totalItems ?? data.length}
|
||||
@@ -260,9 +259,9 @@ function DataTableContent<TData, TValue>({
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<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"
|
||||
style={{ width: '100%', minWidth: table.getTotalSize() }}
|
||||
>
|
||||
@@ -274,7 +273,7 @@ function DataTableContent<TData, TValue>({
|
||||
{columns.map((_, colIdx) => (
|
||||
<TableCell
|
||||
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" />
|
||||
</TableCell>
|
||||
@@ -291,7 +290,9 @@ function DataTableContent<TData, TValue>({
|
||||
const truncate = shouldTruncateCell(cell.column);
|
||||
const pinned = cell.column.getIsPinned();
|
||||
const isActionsCell = cell.column.id === 'actions';
|
||||
const isRowClickable = Boolean(onRowClick && !isActionsCell);
|
||||
const isRowClickable = Boolean(
|
||||
onRowClick && !isActionsCell,
|
||||
);
|
||||
const content = flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
@@ -307,8 +308,8 @@ function DataTableContent<TData, TValue>({
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'max-w-0 overflow-hidden border-b border-border',
|
||||
pinned && 'bg-card',
|
||||
'h-13 max-w-0 overflow-hidden border-b border-border px-4 py-3 text-sm',
|
||||
pinned && 'bg-background',
|
||||
isRowClickable && 'cursor-pointer',
|
||||
isActionsCell && 'cursor-default',
|
||||
)}
|
||||
@@ -346,6 +347,6 @@ function DataTableContent<TData, TValue>({
|
||||
onPageSizeChange={pagination?.onLimitChange}
|
||||
/>
|
||||
</section>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
129
src/components/form/DatePickerSimple.tsx
Normal file
129
src/components/form/DatePickerSimple.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { Matcher } from 'react-day-picker';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CalendarGridPicker } from '@/components/ui/calendar-grid-picker';
|
||||
import { FormField } from '@/components/form/FormField';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DatePickerSimpleProps {
|
||||
value?: Date;
|
||||
defaultValue?: Date;
|
||||
onChange?: (date: Date | undefined) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
showLabel?: boolean;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
disabledDates?: Matcher | Matcher[];
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const next = new Date(date);
|
||||
next.setHours(0, 0, 0, 0);
|
||||
return next;
|
||||
}
|
||||
|
||||
function clampDateToRange(date: Date, min: Date, max: Date) {
|
||||
if (date < min) return min;
|
||||
if (date > max) return max;
|
||||
return date;
|
||||
}
|
||||
|
||||
export function DatePickerSimple(props: DatePickerSimpleProps) {
|
||||
const {
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
label = 'Date of birth',
|
||||
placeholder = 'Select date',
|
||||
disabled = false,
|
||||
className,
|
||||
id = 'date',
|
||||
showLabel = true,
|
||||
startMonth,
|
||||
endMonth,
|
||||
disabledDates,
|
||||
} = props;
|
||||
const isControlled = Object.hasOwn(props, 'value');
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalDate, setInternalDate] = React.useState<Date | undefined>(
|
||||
defaultValue,
|
||||
);
|
||||
|
||||
const selectedDate = isControlled ? value : internalDate;
|
||||
const today = React.useMemo(() => startOfDay(new Date()), []);
|
||||
const resolvedStartMonth = React.useMemo(
|
||||
() => startMonth ?? new Date(today.getFullYear() - 100, 0),
|
||||
[startMonth, today],
|
||||
);
|
||||
const resolvedEndMonth = React.useMemo(
|
||||
() => endMonth ?? today,
|
||||
[endMonth, today],
|
||||
);
|
||||
const initialMonth = selectedDate
|
||||
? startOfDay(selectedDate)
|
||||
: clampDateToRange(today, resolvedStartMonth, resolvedEndMonth);
|
||||
|
||||
const handleSelect = (date: Date | undefined) => {
|
||||
if (!isControlled) setInternalDate(date);
|
||||
onChange?.(date);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const picker = (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
data-empty={!selectedDate}
|
||||
className="w-full justify-start font-normal data-[empty=true]:text-muted-foreground"
|
||||
>
|
||||
{selectedDate
|
||||
? selectedDate.toLocaleDateString(undefined, { dateStyle: 'long' })
|
||||
: placeholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<CalendarGridPicker
|
||||
key={open ? 'open' : 'closed'}
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
defaultMonth={initialMonth}
|
||||
startMonth={resolvedStartMonth}
|
||||
endMonth={resolvedEndMonth}
|
||||
disabled={disabledDates}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
if (!showLabel) {
|
||||
return <div className={cn('w-full', className)}>{picker}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField id={id} label={label} className={className ?? 'mx-auto w-44'}>
|
||||
{picker}
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ interface MultiSelectPopoverProps {
|
||||
|
||||
function checkboxClass(checked: boolean) {
|
||||
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',
|
||||
);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function MultiSelectPopover({
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
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)}>
|
||||
{areAllSelected ? <Check className="size-3" /> : null}
|
||||
@@ -128,7 +128,7 @@ export function MultiSelectPopover({
|
||||
return (
|
||||
<div
|
||||
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
|
||||
type="button"
|
||||
|
||||
@@ -101,7 +101,7 @@ export function SelectPopover({
|
||||
setOpen(false);
|
||||
}}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,3 +5,5 @@ export { PhoneInput } from './phone-input/PhoneInput';
|
||||
export { SelectPopover } from './SelectPopover';
|
||||
export type { SelectPopoverOption } from './SelectPopover';
|
||||
export { PasswordField } from './PasswordField';
|
||||
export { DatePickerSimple } from './DatePickerSimple';
|
||||
export type { DatePickerSimpleProps } from './DatePickerSimple';
|
||||
|
||||
@@ -109,7 +109,7 @@ export function CountryCombobox({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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} />
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function CountryFlag({ country }: { country: CountryCode }) {
|
||||
<div
|
||||
aria-hidden
|
||||
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()}`,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function PhoneInput({
|
||||
return (
|
||||
<div
|
||||
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 &&
|
||||
'border-destructive focus-within:border-destructive focus-within:ring-destructive/20',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
@@ -80,7 +80,7 @@ export function PhoneInput({
|
||||
disabled={disabled}
|
||||
aria-invalid={ariaInvalid}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function DetectionLocationMap({
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{title ? (
|
||||
<p className="text-xs font-medium text-muted-foreground">{title}</p>
|
||||
<p className="text-muted-foreground">{title}</p>
|
||||
) : null}
|
||||
{mapContent}
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@ export default function DetectionLocationMap({
|
||||
<Card className={cn('overflow-hidden', className)}>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ProtectedVideoPlayer({
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
@@ -69,7 +69,7 @@ export function ProtectedVideoPlayer({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-md border bg-black/90',
|
||||
'group relative overflow-hidden rounded-lg border bg-black/90',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -84,7 +84,7 @@ export function ProtectedVideoPlayer({
|
||||
</video>
|
||||
|
||||
{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}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -110,7 +110,7 @@ function VideoState({
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,40 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const toggleTheme = () => {
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Button variant="outline" size="icon" onClick={toggleTheme}>
|
||||
<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" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -39,7 +40,6 @@ export function NavUser({
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
@@ -76,9 +76,10 @@ export function NavUser({
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
<UserRound className="size-4" aria-hidden="true" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
@@ -95,9 +96,10 @@ export function NavUser({
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
<UserRound className="size-4" aria-hidden="true" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
|
||||
199
src/components/ui/alert-dialog.tsx
Normal file
199
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-lg bg-popover p-6 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-16 items-center justify-center rounded-lg bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -17,7 +17,7 @@ function Avatar({
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +32,10 @@ function AvatarImage({
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -59,7 +62,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
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=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",
|
||||
@@ -103,7 +106,7 @@ export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarBadge,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
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:
|
||||
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 [a&]:hover:underline',
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = 'default',
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'span';
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -42,7 +43,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -1,95 +1,114 @@
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import * as React from "react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
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 (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground sm:gap-2.5',
|
||||
className,
|
||||
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
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}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Link
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
{children ?? (
|
||||
<ChevronRightIcon />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
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}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<MoreHorizontalIcon
|
||||
/>
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -100,4 +119,4 @@ export {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,29 +5,32 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
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:
|
||||
"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:
|
||||
"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",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
default:
|
||||
"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",
|
||||
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",
|
||||
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-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-xs":
|
||||
"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",
|
||||
},
|
||||
},
|
||||
|
||||
537
src/components/ui/calendar-grid-picker.tsx
Normal file
537
src/components/ui/calendar-grid-picker.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { useDayPicker, type MonthCaptionProps } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, CalendarDayButton } from '@/components/ui/calendar';
|
||||
|
||||
type CalendarView = 'days' | 'months' | 'years';
|
||||
|
||||
const MONTH_LABELS = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
] as const;
|
||||
|
||||
const PICKER_WIDTH = 'w-[280px]';
|
||||
const PICKER_SHELL_CLASS =
|
||||
'group/calendar p-3 text-popover-foreground [--cell-size:--spacing(8)]';
|
||||
const PICKER_HEADER_CLASS =
|
||||
'grid min-h-8 grid-cols-[2rem_1fr_2rem] items-center gap-1 border-b border-border pb-2';
|
||||
const PICKER_NAV_BUTTON_CLASS = 'size-8 shrink-0 p-0';
|
||||
const GRID_BUTTON_CLASS =
|
||||
'h-9 w-full rounded-lg font-medium text-foreground hover:bg-accent/60 hover:text-accent-foreground';
|
||||
const GRID_BUTTON_ACTIVE_CLASS =
|
||||
'bg-muted text-foreground hover:bg-muted hover:text-foreground';
|
||||
const GRID_BUTTON_DISABLED_CLASS =
|
||||
'text-muted-foreground opacity-50 hover:bg-transparent hover:text-muted-foreground';
|
||||
|
||||
function getMonthIndex(date: Date) {
|
||||
return date.getFullYear() * 12 + date.getMonth();
|
||||
}
|
||||
|
||||
function isYearOutsideRange(year: number, startMonth?: Date, endMonth?: Date) {
|
||||
if (startMonth && year < startMonth.getFullYear()) return true;
|
||||
if (endMonth && year > endMonth.getFullYear()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMonthOutsideRange(
|
||||
year: number,
|
||||
month: number,
|
||||
startMonth?: Date,
|
||||
endMonth?: Date,
|
||||
) {
|
||||
const monthIndex = year * 12 + month;
|
||||
|
||||
if (startMonth && monthIndex < getMonthIndex(startMonth)) return true;
|
||||
if (endMonth && monthIndex > getMonthIndex(endMonth)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function clampMonthToRange(
|
||||
year: number,
|
||||
month: number,
|
||||
startMonth?: Date,
|
||||
endMonth?: Date,
|
||||
) {
|
||||
let nextMonth = month;
|
||||
|
||||
if (startMonth && year === startMonth.getFullYear()) {
|
||||
nextMonth = Math.max(nextMonth, startMonth.getMonth());
|
||||
}
|
||||
|
||||
if (endMonth && year === endMonth.getFullYear()) {
|
||||
nextMonth = Math.min(nextMonth, endMonth.getMonth());
|
||||
}
|
||||
|
||||
return Math.min(11, Math.max(0, nextMonth));
|
||||
}
|
||||
|
||||
type CalendarGridPickerContextValue = {
|
||||
view: CalendarView;
|
||||
setView: (view: CalendarView) => void;
|
||||
pickerYear: number;
|
||||
setPickerYear: React.Dispatch<React.SetStateAction<number>>;
|
||||
decadeStart: number;
|
||||
setDecadeStart: React.Dispatch<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
const CalendarGridPickerContext =
|
||||
React.createContext<CalendarGridPickerContextValue | null>(null);
|
||||
|
||||
function useCalendarGridPicker() {
|
||||
const context = React.useContext(CalendarGridPickerContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useCalendarGridPicker must be used within CalendarGridPicker',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function PickerNav({
|
||||
label,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onLabelClick,
|
||||
disablePrevious = false,
|
||||
disableNext = false,
|
||||
}: {
|
||||
label: string;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onLabelClick?: () => void;
|
||||
disablePrevious?: boolean;
|
||||
disableNext?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={PICKER_HEADER_CLASS}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-start')}
|
||||
onClick={onPrevious}
|
||||
disabled={disablePrevious}
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
{onLabelClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg px-2 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onLabelClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="px-2 text-center text-sm font-semibold text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-end')}
|
||||
onClick={onNext}
|
||||
disabled={disableNext}
|
||||
aria-label="Next"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthGrid({
|
||||
year,
|
||||
activeMonth,
|
||||
onSelect,
|
||||
startMonth,
|
||||
endMonth,
|
||||
}: {
|
||||
year: number;
|
||||
activeMonth: number;
|
||||
onSelect: (month: number) => void;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-1.5 pt-2">
|
||||
{MONTH_LABELS.map((label, month) => {
|
||||
const isDisabled = isMonthOutsideRange(
|
||||
year,
|
||||
month,
|
||||
startMonth,
|
||||
endMonth,
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
GRID_BUTTON_CLASS,
|
||||
isDisabled && GRID_BUTTON_DISABLED_CLASS,
|
||||
activeMonth === month && !isDisabled && GRID_BUTTON_ACTIVE_CLASS,
|
||||
)}
|
||||
onClick={() => onSelect(month)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGrid({
|
||||
decadeStart,
|
||||
activeYear,
|
||||
onSelect,
|
||||
startMonth,
|
||||
endMonth,
|
||||
}: {
|
||||
decadeStart: number;
|
||||
activeYear: number;
|
||||
onSelect: (year: number) => void;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
}) {
|
||||
const years = React.useMemo(
|
||||
() => Array.from({ length: 10 }, (_, index) => decadeStart + index),
|
||||
[decadeStart],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 pt-2">
|
||||
{years.map((year) => {
|
||||
const isDisabled = isYearOutsideRange(year, startMonth, endMonth);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={year}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
GRID_BUTTON_CLASS,
|
||||
isDisabled && GRID_BUTTON_DISABLED_CLASS,
|
||||
activeYear === year && !isDisabled && GRID_BUTTON_ACTIVE_CLASS,
|
||||
)}
|
||||
onClick={() => onSelect(year)}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarMonthCaption({
|
||||
calendarMonth,
|
||||
className,
|
||||
children: _children,
|
||||
displayIndex: _displayIndex,
|
||||
...props
|
||||
}: MonthCaptionProps) {
|
||||
const { goToMonth, previousMonth, nextMonth } = useDayPicker();
|
||||
const { setView, setPickerYear, setDecadeStart } = useCalendarGridPicker();
|
||||
const date = calendarMonth.date;
|
||||
const monthName = date.toLocaleString('default', { month: 'long' });
|
||||
const year = date.getFullYear();
|
||||
|
||||
return (
|
||||
<div className={cn(PICKER_HEADER_CLASS, className)} {...props}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-start')}
|
||||
disabled={!previousMonth}
|
||||
aria-label="Previous month"
|
||||
onClick={() => previousMonth && goToMonth(previousMonth)}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-1 text-sm font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg px-1 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
setPickerYear(year);
|
||||
setView('months');
|
||||
}}
|
||||
>
|
||||
{monthName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg px-1 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
setDecadeStart(Math.floor(year / 10) * 10);
|
||||
setPickerYear(year);
|
||||
setView('years');
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-end')}
|
||||
disabled={!nextMonth}
|
||||
aria-label="Next month"
|
||||
onClick={() => nextMonth && goToMonth(nextMonth)}
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarGridPickerPanel({
|
||||
month,
|
||||
onMonthChange,
|
||||
startMonth,
|
||||
endMonth,
|
||||
}: {
|
||||
month: Date;
|
||||
onMonthChange: (month: Date) => void;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
}) {
|
||||
const {
|
||||
view,
|
||||
setView,
|
||||
pickerYear,
|
||||
setPickerYear,
|
||||
decadeStart,
|
||||
setDecadeStart,
|
||||
} = useCalendarGridPicker();
|
||||
|
||||
const minYear = startMonth?.getFullYear();
|
||||
const maxYear = endMonth?.getFullYear();
|
||||
const activeMonth = clampMonthToRange(
|
||||
pickerYear,
|
||||
month.getMonth(),
|
||||
startMonth,
|
||||
endMonth,
|
||||
);
|
||||
|
||||
if (view === 'months') {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PickerNav
|
||||
label={String(pickerYear)}
|
||||
onPrevious={() => setPickerYear((year) => year - 1)}
|
||||
onNext={() => setPickerYear((year) => year + 1)}
|
||||
onLabelClick={() => {
|
||||
setDecadeStart(Math.floor(pickerYear / 10) * 10);
|
||||
setView('years');
|
||||
}}
|
||||
disablePrevious={minYear !== undefined && pickerYear <= minYear}
|
||||
disableNext={maxYear !== undefined && pickerYear >= maxYear}
|
||||
/>
|
||||
<MonthGrid
|
||||
year={pickerYear}
|
||||
activeMonth={activeMonth}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
onSelect={(nextMonth) => {
|
||||
onMonthChange(new Date(pickerYear, nextMonth, 1));
|
||||
setView('days');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'years') {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PickerNav
|
||||
label={`${decadeStart} - ${decadeStart + 9}`}
|
||||
onPrevious={() => setDecadeStart((start) => start - 10)}
|
||||
onNext={() => setDecadeStart((start) => start + 10)}
|
||||
disablePrevious={minYear !== undefined && decadeStart <= minYear}
|
||||
disableNext={maxYear !== undefined && decadeStart + 10 > maxYear}
|
||||
/>
|
||||
<YearGrid
|
||||
decadeStart={decadeStart}
|
||||
activeYear={pickerYear}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
onSelect={(year) => {
|
||||
const nextMonth = clampMonthToRange(
|
||||
year,
|
||||
activeMonth,
|
||||
startMonth,
|
||||
endMonth,
|
||||
);
|
||||
|
||||
setPickerYear(year);
|
||||
onMonthChange(new Date(year, nextMonth, 1));
|
||||
setView('months');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type CalendarGridPickerProps = Omit<
|
||||
Extract<React.ComponentProps<typeof Calendar>, { mode: 'single' }>,
|
||||
'captionLayout' | 'navLayout' | 'hideNavigation'
|
||||
> & {
|
||||
onViewChange?: (view: CalendarView) => void;
|
||||
};
|
||||
|
||||
function CalendarGridPicker({
|
||||
className,
|
||||
classNames,
|
||||
components,
|
||||
formatters,
|
||||
month,
|
||||
defaultMonth,
|
||||
onMonthChange,
|
||||
onViewChange,
|
||||
startMonth,
|
||||
endMonth,
|
||||
...props
|
||||
}: CalendarGridPickerProps) {
|
||||
const initialMonth = month ?? defaultMonth ?? new Date();
|
||||
const [view, setView] = React.useState<CalendarView>('days');
|
||||
const [pickerYear, setPickerYear] = React.useState(
|
||||
initialMonth.getFullYear(),
|
||||
);
|
||||
const [decadeStart, setDecadeStart] = React.useState(
|
||||
Math.floor(initialMonth.getFullYear() / 10) * 10,
|
||||
);
|
||||
const [internalMonth, setInternalMonth] = React.useState(initialMonth);
|
||||
|
||||
const currentMonth = month ?? internalMonth;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (month) {
|
||||
setPickerYear(month.getFullYear());
|
||||
setDecadeStart(Math.floor(month.getFullYear() / 10) * 10);
|
||||
}
|
||||
}, [month]);
|
||||
|
||||
const handleViewChange = React.useCallback(
|
||||
(nextView: CalendarView) => {
|
||||
setView(nextView);
|
||||
onViewChange?.(nextView);
|
||||
},
|
||||
[onViewChange],
|
||||
);
|
||||
|
||||
const handleMonthChange = React.useCallback(
|
||||
(nextMonth: Date) => {
|
||||
if (!month) setInternalMonth(nextMonth);
|
||||
setPickerYear(nextMonth.getFullYear());
|
||||
setDecadeStart(Math.floor(nextMonth.getFullYear() / 10) * 10);
|
||||
onMonthChange?.(nextMonth);
|
||||
},
|
||||
[month, onMonthChange],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
view,
|
||||
setView: handleViewChange,
|
||||
pickerYear,
|
||||
setPickerYear,
|
||||
decadeStart,
|
||||
setDecadeStart,
|
||||
}),
|
||||
[view, handleViewChange, pickerYear, decadeStart],
|
||||
);
|
||||
|
||||
if (view !== 'days') {
|
||||
return (
|
||||
<CalendarGridPickerContext.Provider value={contextValue}>
|
||||
<div className={cn(PICKER_SHELL_CLASS, PICKER_WIDTH, className)}>
|
||||
<CalendarGridPickerPanel
|
||||
month={currentMonth}
|
||||
onMonthChange={handleMonthChange}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
/>
|
||||
</div>
|
||||
</CalendarGridPickerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarGridPickerContext.Provider value={contextValue}>
|
||||
<Calendar
|
||||
hideNavigation
|
||||
className={cn(PICKER_SHELL_CLASS, PICKER_WIDTH, className)}
|
||||
month={currentMonth}
|
||||
onMonthChange={handleMonthChange}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
formatters={{
|
||||
formatWeekdayName: (date) =>
|
||||
date.toLocaleString('en-US', { weekday: 'short' }).slice(0, 2),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
month: 'flex w-full flex-col gap-2',
|
||||
month_caption: 'p-0',
|
||||
weekdays: 'flex w-full',
|
||||
weekday:
|
||||
'flex-1 text-center text-[0.8rem] font-medium text-muted-foreground select-none',
|
||||
week: 'mt-1 flex w-full',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
MonthCaption: CalendarMonthCaption,
|
||||
DayButton: (dayButtonProps) => (
|
||||
<CalendarDayButton
|
||||
{...dayButtonProps}
|
||||
className={cn(
|
||||
dayButtonProps.className,
|
||||
'data-[selected-single=true]:rounded-full data-[selected-single=true]:bg-muted data-[selected-single=true]:text-foreground',
|
||||
)}
|
||||
/>
|
||||
),
|
||||
...components,
|
||||
}}
|
||||
{...(props as Extract<
|
||||
React.ComponentProps<typeof Calendar>,
|
||||
{ mode: 'single' }
|
||||
>)}
|
||||
/>
|
||||
</CalendarGridPickerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { CalendarGridPicker, type CalendarView };
|
||||
@@ -1,19 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
type Locale,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
@@ -21,6 +18,7 @@ function Calendar({
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
@@ -33,15 +31,16 @@ function Calendar({
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
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\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
date.toLocaleString(locale?.code, { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
@@ -74,7 +73,7 @@ function Calendar({
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
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
|
||||
),
|
||||
dropdown: cn(
|
||||
@@ -85,13 +84,13 @@ function Calendar({
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "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
|
||||
),
|
||||
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
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
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
@@ -104,20 +103,23 @@ function Calendar({
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
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
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-lg"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-lg",
|
||||
defaultClassNames.day
|
||||
),
|
||||
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
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
range_middle: cn("rounded-lg", defaultClassNames.range_middle),
|
||||
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(
|
||||
"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
|
||||
),
|
||||
outside: cn(
|
||||
@@ -151,10 +153,7 @@ function Calendar({
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,7 +161,9 @@ function Calendar({
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
DayButton: ({ ...props }) => (
|
||||
<CalendarDayButton locale={locale} {...props} />
|
||||
),
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
@@ -183,8 +184,9 @@ function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
@@ -197,7 +199,7 @@ function CalendarDayButton({
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
@@ -208,7 +210,7 @@ function CalendarDayButton({
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
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,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-xl border bg-card text-card-foreground',
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
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]',
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-5 py-5', className)}
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -89,4 +100,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
'use client';
|
||||
"use client"
|
||||
|
||||
import * as React from 'react';
|
||||
import * as RechartsPrimitive from 'recharts';
|
||||
import * as React from "react"
|
||||
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 }
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />');
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
return context;
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
@@ -39,15 +44,20 @@ function ChartContainer({
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>['children'];
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
@@ -55,27 +65,29 @@ function ChartContainer({
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
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",
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -88,27 +100,27 @@ ${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join('\n')}
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
`
|
||||
)
|
||||
.join('\n'),
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
@@ -119,41 +131,47 @@ function ChartTooltipContent({
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
labelFormatter,
|
||||
@@ -162,34 +180,36 @@ function ChartTooltipContent({
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
key={index}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center',
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
@@ -202,19 +222,19 @@ function ChartTooltipContent({
|
||||
!hideIndicator && (
|
||||
<div
|
||||
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',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
@@ -222,75 +242,78 @@ function ChartTooltipContent({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center',
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<'div'> &
|
||||
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className,
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={
|
||||
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
|
||||
}
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
className="h-2 w-2 shrink-0 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
@@ -298,49 +321,46 @@ function ChartLegendContent({
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined;
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload &&
|
||||
typeof payload.payload === 'object' &&
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key;
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
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 (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -350,4 +370,4 @@ export {
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
@@ -27,7 +27,7 @@ function CollapsibleContent({
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
@@ -1,63 +1,39 @@
|
||||
'use client';
|
||||
"use client"
|
||||
|
||||
import * as React from 'react';
|
||||
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from 'lucide-react';
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@/components/ui/input-group';
|
||||
} from "@/components/ui/input-group"
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
const icon = (
|
||||
<ChevronDownIcon
|
||||
data-slot="combobox-trigger-icon"
|
||||
className="pointer-events-none size-4 text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(
|
||||
render,
|
||||
)
|
||||
? React.cloneElement(render, {
|
||||
children: (
|
||||
<>
|
||||
{render.props.children}
|
||||
{icon}
|
||||
</>
|
||||
),
|
||||
})
|
||||
: render;
|
||||
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn(
|
||||
"cursor-pointer disabled:cursor-not-allowed [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
render={triggerRender}
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{render ? null : icon}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
@@ -70,7 +46,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
@@ -81,11 +57,11 @@ function ComboboxInput({
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn('w-auto', className)}>
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
@@ -107,14 +83,14 @@ function ComboboxInput({
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = 'bottom',
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = 'start',
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
container,
|
||||
@@ -122,9 +98,9 @@ function ComboboxContent({
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
> & {
|
||||
container?: ComboboxPrimitive.Portal.Props['container'];
|
||||
container?: ComboboxPrimitive.Portal.Props["container"]
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal container={container}>
|
||||
@@ -139,15 +115,12 @@ function ComboboxContent({
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
'group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+1.75rem)] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none 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,
|
||||
)}
|
||||
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none 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}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
@@ -155,12 +128,12 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
'max-h-[min(21.75rem,calc(var(--available-height)-2.25rem))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0',
|
||||
className,
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
@@ -172,22 +145,21 @@ function ComboboxItem({
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
data-slot="combobox-item-indicator"
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
@@ -197,7 +169,7 @@ function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
@@ -207,19 +179,16 @@ function ComboboxLabel({
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-xs text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm',
|
||||
className,
|
||||
)}
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
@@ -227,12 +196,12 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
'hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex',
|
||||
className,
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
@@ -242,10 +211,10 @@ function ComboboxSeparator({
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
@@ -257,12 +226,12 @@ function ComboboxChips({
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
'flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm transition-[color,box-shadow] focus-within:border-ring has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive',
|
||||
className,
|
||||
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
@@ -271,14 +240,14 @@ function ComboboxChip({
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
'flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0',
|
||||
className,
|
||||
"flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-lg bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -293,25 +262,24 @@ function ComboboxChip({
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn('min-w-16 flex-1 outline-none', className)}
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -331,4 +299,4 @@ export {
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
'use client';
|
||||
"use client"
|
||||
|
||||
import * as React from 'react';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@@ -39,61 +39,70 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 backdrop-blur-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className,
|
||||
"fixed inset-0 isolate 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 DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
onInteractOutside,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
|
||||
className,
|
||||
"fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg bg-popover text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md 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
|
||||
)}
|
||||
onInteractOutside={(event) => {
|
||||
event.preventDefault();
|
||||
onInteractOutside?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon />
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
className={cn("flex shrink-0 flex-col gap-2 border-b px-6 py-4 pr-12", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DialogBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-body"
|
||||
className={cn("min-h-0 flex-1 overflow-y-auto px-6 py-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -101,15 +110,15 @@ function DialogFooter({
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showCloseButton?: boolean;
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
"flex shrink-0 flex-col-reverse gap-2 border-t px-6 py-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -120,7 +129,7 @@ function DialogFooter({
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
@@ -130,10 +139,10 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(className)}
|
||||
className={cn("font-heading leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@@ -143,14 +152,18 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground', className)}
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
@@ -160,4 +173,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function DrawerOverlay({
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/10 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}
|
||||
@@ -56,16 +56,12 @@ function DrawerContent({
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-lg data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-lg data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-lg data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-lg data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
<div className="mx-auto mt-4 hidden h-1.5 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
@@ -102,7 +98,7 @@ function DrawerTitle({
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
className={cn("font-heading font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
'use client';
|
||||
"use client"
|
||||
|
||||
import * as React from 'react';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@@ -28,11 +28,12 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
@@ -41,14 +42,12 @@ function DropdownMenuContent({
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden 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}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@@ -56,17 +55,17 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -74,38 +73,46 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className,
|
||||
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
"relative flex cursor-default items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@@ -116,31 +123,39 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
"relative flex cursor-default items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -148,19 +163,19 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -170,32 +185,32 @@ function DropdownMenuSeparator({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className,
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -204,22 +219,22 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
"flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -229,13 +244,10 @@ function DropdownMenuSubContent({
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -254,4 +266,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user