27 Commits

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

View File

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

View File

@@ -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": {}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

After

Width:  |  Height:  |  Size: 1.6 KiB

5
public/logo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -33,7 +33,7 @@ function ResetPasswordContent() {
</header>
{!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>
) : (

View File

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

View File

@@ -13,6 +13,7 @@ import { FormField, PhoneInput } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
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"

View File

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

View File

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

View File

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

View File

@@ -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>
) : (

View File

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

View File

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

View File

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

View File

@@ -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>
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,85 +1,71 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,29 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Card,
CardAction,
CardContent,
CardHeader,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
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>
);
}

View File

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

View File

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

View File

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

View File

@@ -89,7 +89,7 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
</CardHeader>
<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" />}

View File

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

View File

@@ -1,485 +0,0 @@
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Camera,
ImageIcon,
Loader2,
Play,
Plus,
Send,
Video,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
const MAX_IMAGES = 30;
const MAX_NOTES = 500;
function RequiredMark() {
return <span className="text-destructive">*</span>;
}
function formatVideoDuration(seconds: number) {
const totalSeconds = Math.floor(seconds);
const minutes = Math.floor(totalSeconds / 60);
const remainingSeconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
}
interface MediaSectionHeaderProps {
icon: ReactNode;
title: string;
addLabel: string;
onAdd: () => void;
disabled?: boolean;
}
function MediaSectionHeader({
icon,
title,
addLabel,
onAdd,
disabled = false,
}: MediaSectionHeaderProps) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-primary">{icon}</span>
<span className="text-sm font-semibold">{title}</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={onAdd}
className="h-8 shrink-0 border-primary/30 text-primary hover:bg-primary/5 hover:text-primary"
>
<Plus className="size-3.5" />
{addLabel}
</Button>
</div>
);
}
function MediaHint({ children }: { children: ReactNode }) {
return (
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
{children}
</p>
);
}
interface EmptyMediaPlaceholderProps {
icon: ReactNode;
title: string;
description: string;
className?: string;
disabled?: boolean;
onClick: () => void;
}
function EmptyMediaPlaceholder({
icon,
title,
description,
className,
disabled = false,
onClick,
}: EmptyMediaPlaceholderProps) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'flex w-full min-w-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/80 bg-muted/15 px-4 py-6 text-center transition-colors',
'hover:border-primary/40 hover:bg-primary/5',
disabled && 'cursor-not-allowed opacity-60',
className,
)}
>
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
{icon}
</span>
<span className="text-sm font-medium text-foreground">{title}</span>
<span className="max-w-xs text-xs text-muted-foreground">
{description}
</span>
</button>
);
}
interface ImageThumbnailProps {
file: File;
previewUrl: string;
disabled?: boolean;
onRemove: () => void;
}
function ImageThumbnail({
file,
previewUrl,
disabled = false,
onRemove,
}: ImageThumbnailProps) {
return (
<div className="group relative size-20 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={file.name}
className="size-full object-cover"
/>
<button
type="button"
disabled={disabled}
onClick={onRemove}
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${file.name}`}
>
<X className="size-3" />
</button>
</div>
);
}
interface VideoThumbnailProps {
file: File;
previewUrl: string;
duration: string | null;
disabled?: boolean;
onRemove: () => void;
}
function VideoThumbnail({
file,
previewUrl,
duration,
disabled = false,
onRemove,
}: VideoThumbnailProps) {
return (
<div className="group relative h-20 w-36 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
<video
src={previewUrl}
className="size-full object-cover"
muted
playsInline
preload="metadata"
aria-label={file.name}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
<span className="flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm">
<Play className="size-3.5 fill-current" />
</span>
</div>
{duration ? (
<span className="pointer-events-none absolute bottom-1.5 right-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
{duration}
</span>
) : null}
<button
type="button"
disabled={disabled}
onClick={onRemove}
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${file.name}`}
>
<X className="size-3" />
</button>
</div>
);
}
interface RepairEvidenceFormProps {
ticketId: string;
defectClass: string;
}
export function RepairEvidenceForm({
ticketId,
defectClass,
}: RepairEvidenceFormProps) {
const imageInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
const [notes, setNotes] = useState('');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [videoDuration, setVideoDuration] = useState<string | null>(null);
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
const isSubmitting = submitRepairUploadMutation.isPending;
const canSubmit = Boolean(notes.trim() && videoFile);
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
useEffect(() => {
const urls = imageFiles.map((file) => URL.createObjectURL(file));
setImagePreviews(urls);
return () => {
urls.forEach((url) => URL.revokeObjectURL(url));
};
}, [imageFiles]);
useEffect(() => {
if (!videoFile) {
setVideoPreview(null);
setVideoDuration(null);
return;
}
const url = URL.createObjectURL(videoFile);
setVideoPreview(url);
setVideoDuration(null);
let cancelled = false;
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
if (cancelled || !Number.isFinite(video.duration)) return;
setVideoDuration(formatVideoDuration(video.duration));
};
video.src = url;
return () => {
cancelled = true;
URL.revokeObjectURL(url);
};
}, [videoFile]);
const addImages = (files: FileList | null) => {
if (!files?.length) return;
const remaining = MAX_IMAGES - imageFiles.length;
if (remaining <= 0) {
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
return;
}
const nextImages = [
...imageFiles,
...Array.from(files).slice(0, remaining),
];
setImageFiles(nextImages);
setError(
files.length > remaining
? `Only ${MAX_IMAGES} images can be uploaded.`
: null,
);
};
const selectVideo = (files: FileList | null) => {
setVideoFile(files?.[0] ?? null);
setError(null);
};
const removeVideo = () => {
setVideoFile(null);
setError(null);
};
const removeImage = (index: number) => {
setImageFiles((current) =>
current.filter((_, itemIndex) => itemIndex !== index),
);
setError(null);
};
const handleSubmit = async () => {
if (!notes.trim() || !videoFile) {
setError('Repair notes and a video are required.');
return;
}
setError(null);
try {
await submitRepairUploadMutation.mutateAsync({
defect_class: defectClass,
notes: notes.trim(),
video: videoFile,
images: imageFiles,
});
setNotes('');
setVideoFile(null);
setImageFiles([]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
}
};
const openImagePicker = () => {
if (!isSubmitting && canAddMoreImages) {
imageInputRef.current?.click();
}
};
const openVideoPicker = () => {
if (!isSubmitting) {
videoInputRef.current?.click();
}
};
return (
<div className="space-y-5">
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
<Camera className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="space-y-1">
<p className="text-sm font-semibold">Instructions</p>
<p className="text-sm text-muted-foreground">
Upload clear photos and one video of the repaired area and provide
relevant details.
</p>
</div>
</div>
<input
ref={imageInputRef}
type="file"
accept="image/jpeg,image/png,image/*"
multiple
className="hidden"
disabled={isSubmitting || !canAddMoreImages}
onChange={(event) => {
addImages(event.target.files);
event.target.value = '';
}}
/>
<input
ref={videoInputRef}
type="file"
accept="video/mp4,video/quicktime,video/x-msvideo,video/*"
className="hidden"
disabled={isSubmitting}
onChange={(event) => {
selectVideo(event.target.files);
event.target.value = '';
}}
/>
<section className="space-y-3">
<MediaSectionHeader
icon={<Camera className="size-4" />}
title="Photos"
addLabel="Add Photos"
onAdd={openImagePicker}
disabled={isSubmitting || !canAddMoreImages}
/>
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{imageFiles.length === 0 ? (
<EmptyMediaPlaceholder
icon={<ImageIcon className="size-5" />}
title="No photos added yet"
description="Tap to upload repair photos"
disabled={isSubmitting}
onClick={openImagePicker}
className="min-h-[104px]"
/>
) : (
<div className="flex min-w-min gap-3">
{imageFiles.map((file, index) => (
<ImageThumbnail
key={`${file.name}-${file.lastModified}-${index}`}
file={file}
previewUrl={imagePreviews[index]}
disabled={isSubmitting}
onRemove={() => removeImage(index)}
/>
))}
</div>
)}
</div>
<MediaHint>
You can upload up to {MAX_IMAGES} images (JPG, PNG)
</MediaHint>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<Video className="size-4 text-primary" />
<span className="text-sm font-semibold">Video</span>
</div>
<div>
{!videoFile || !videoPreview ? (
<EmptyMediaPlaceholder
icon={<Video className="size-5" />}
title="No video added yet"
description="Tap to upload a repair video"
disabled={isSubmitting}
onClick={openVideoPicker}
className="min-h-[104px]"
/>
) : (
<VideoThumbnail
file={videoFile}
previewUrl={videoPreview}
duration={videoDuration}
disabled={isSubmitting}
onRemove={removeVideo}
/>
)}
</div>
<MediaHint>You can upload one video (MP4, MOV, AVI)</MediaHint>
</section>
<div className="space-y-2">
<Label htmlFor="repair-notes">
Repair Notes <RequiredMark />
</Label>
<div className="relative">
<Textarea
id="repair-notes"
value={notes}
onChange={(event) => {
setNotes(event.target.value.slice(0, MAX_NOTES));
setError(null);
}}
placeholder="Describe the repair work completed, materials used, and any additional observations."
disabled={isSubmitting}
className="min-h-28 resize-none pb-8"
/>
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
{notes.length} / {MAX_NOTES}
</span>
</div>
</div>
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<Button
type="button"
className="w-full"
disabled={!canSubmit || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Submitting...
</>
) : (
<>
<Send className="size-4" />
Submit for Review
</>
)}
</Button>
</div>
);
}

View File

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

View File

@@ -1,9 +1,10 @@
'use client';
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>
);
}

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
'use client';
import { useState } from 'react';
import { Wrench } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { TicketDetail } from '@/types';
import { RepairEvidenceForm } from './RepairEvidenceForm';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
import { TicketActionCard } from './TicketActionCard';
type RepairActionTab = 'repair-proof' | 'request-extension';
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
if (ticket.repair?.submitted_at) {
return null;
}
return (
<TicketActionCard icon={Wrench} title="Repair Proof">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as RepairActionTab)}
className="gap-5"
>
<TabsList variant="line" className="grid h-auto w-full grid-cols-2 p-0">
<TabsTrigger value="repair-proof" className="pb-3">
Upload Repair Proof
</TabsTrigger>
<TabsTrigger value="request-extension" className="pb-3">
Request Extension
</TabsTrigger>
</TabsList>
<TabsContent value="repair-proof">
<RepairEvidenceForm
ticketId={ticket.id}
defectClass={ticket.defect_class}
/>
</TabsContent>
<TabsContent value="request-extension">
<TicketExtensionRequestPanel
ticket={ticket}
onCancelRequest={() => setActiveTab('repair-proof')}
/>
</TabsContent>
</Tabs>
</TicketActionCard>
);
}

View File

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

View File

@@ -22,7 +22,7 @@ export function TicketActionCard({
<Card>
<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>

View File

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

View File

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

View File

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

View File

@@ -58,15 +58,17 @@ export default function TicketDetailPage() {
}
return (
<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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ export function UploadFileDropzone({
handleFiles(event.dataTransfer.files);
}}
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>

View File

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

View File

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

View File

@@ -1,44 +0,0 @@
'use client';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { UploadListItem } from '@/types';
interface UploadVideoDialogProps {
upload: UploadListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UploadVideoDialog({
upload,
open,
onOpenChange,
}: UploadVideoDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-3 p-3 sm:max-w-5xl sm:p-4">
<DialogHeader className="min-w-0 pr-8">
<DialogTitle className="truncate text-left text-base">
Video Preview
</DialogTitle>
<DialogDescription className="truncate text-xs">
{upload?.video_name ?? 'Uploaded video'}
</DialogDescription>
</DialogHeader>
<ProtectedVideoPlayer
url={open ? upload?.raw_video_url : undefined}
className="rounded-lg"
unavailableTitle="Video preview unavailable"
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -10,7 +10,7 @@ import type { UploadListItem } from '@/types';
import { CreateUploadDialog } from './components/CreateUploadDialog';
import { 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);
}}
/>
</>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
'use client';
import * as React from 'react';
import type { Matcher } from 'react-day-picker';
import { Button } from '@/components/ui/button';
import { CalendarGridPicker } from '@/components/ui/calendar-grid-picker';
import { FormField } from '@/components/form/FormField';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export interface DatePickerSimpleProps {
value?: Date;
defaultValue?: Date;
onChange?: (date: Date | undefined) => void;
label?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
id?: string;
showLabel?: boolean;
startMonth?: Date;
endMonth?: Date;
disabledDates?: Matcher | Matcher[];
}
function startOfDay(date: Date) {
const next = new Date(date);
next.setHours(0, 0, 0, 0);
return next;
}
function clampDateToRange(date: Date, min: Date, max: Date) {
if (date < min) return min;
if (date > max) return max;
return date;
}
export function DatePickerSimple(props: DatePickerSimpleProps) {
const {
value,
defaultValue,
onChange,
label = 'Date of birth',
placeholder = 'Select date',
disabled = false,
className,
id = 'date',
showLabel = true,
startMonth,
endMonth,
disabledDates,
} = props;
const isControlled = Object.hasOwn(props, 'value');
const [open, setOpen] = React.useState(false);
const [internalDate, setInternalDate] = React.useState<Date | undefined>(
defaultValue,
);
const selectedDate = isControlled ? value : internalDate;
const today = React.useMemo(() => startOfDay(new Date()), []);
const resolvedStartMonth = React.useMemo(
() => startMonth ?? new Date(today.getFullYear() - 100, 0),
[startMonth, today],
);
const resolvedEndMonth = React.useMemo(
() => endMonth ?? today,
[endMonth, today],
);
const initialMonth = selectedDate
? startOfDay(selectedDate)
: clampDateToRange(today, resolvedStartMonth, resolvedEndMonth);
const handleSelect = (date: Date | undefined) => {
if (!isControlled) setInternalDate(date);
onChange?.(date);
setOpen(false);
};
const picker = (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
id={id}
disabled={disabled}
data-empty={!selectedDate}
className="w-full justify-start font-normal data-[empty=true]:text-muted-foreground"
>
{selectedDate
? selectedDate.toLocaleDateString(undefined, { dateStyle: 'long' })
: placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<CalendarGridPicker
key={open ? 'open' : 'closed'}
mode="single"
selected={selectedDate}
defaultMonth={initialMonth}
startMonth={resolvedStartMonth}
endMonth={resolvedEndMonth}
disabled={disabledDates}
onSelect={handleSelect}
/>
</PopoverContent>
</Popover>
);
if (!showLabel) {
return <div className={cn('w-full', className)}>{picker}</div>;
}
return (
<FormField id={id} label={label} className={className ?? 'mx-auto w-44'}>
{picker}
</FormField>
);
}

View File

@@ -32,7 +32,7 @@ interface MultiSelectPopoverProps {
function checkboxClass(checked: boolean) {
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-lg bg-popover p-6 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-lg bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -17,7 +17,7 @@ function Avatar({
data-slot="avatar"
data-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,
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,537 @@
'use client';
import * as React from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { useDayPicker, type MonthCaptionProps } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Calendar, CalendarDayButton } from '@/components/ui/calendar';
type CalendarView = 'days' | 'months' | 'years';
const MONTH_LABELS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
] as const;
const PICKER_WIDTH = 'w-[280px]';
const PICKER_SHELL_CLASS =
'group/calendar p-3 text-popover-foreground [--cell-size:--spacing(8)]';
const PICKER_HEADER_CLASS =
'grid min-h-8 grid-cols-[2rem_1fr_2rem] items-center gap-1 border-b border-border pb-2';
const PICKER_NAV_BUTTON_CLASS = 'size-8 shrink-0 p-0';
const GRID_BUTTON_CLASS =
'h-9 w-full rounded-lg font-medium text-foreground hover:bg-accent/60 hover:text-accent-foreground';
const GRID_BUTTON_ACTIVE_CLASS =
'bg-muted text-foreground hover:bg-muted hover:text-foreground';
const GRID_BUTTON_DISABLED_CLASS =
'text-muted-foreground opacity-50 hover:bg-transparent hover:text-muted-foreground';
function getMonthIndex(date: Date) {
return date.getFullYear() * 12 + date.getMonth();
}
function isYearOutsideRange(year: number, startMonth?: Date, endMonth?: Date) {
if (startMonth && year < startMonth.getFullYear()) return true;
if (endMonth && year > endMonth.getFullYear()) return true;
return false;
}
function isMonthOutsideRange(
year: number,
month: number,
startMonth?: Date,
endMonth?: Date,
) {
const monthIndex = year * 12 + month;
if (startMonth && monthIndex < getMonthIndex(startMonth)) return true;
if (endMonth && monthIndex > getMonthIndex(endMonth)) return true;
return false;
}
function clampMonthToRange(
year: number,
month: number,
startMonth?: Date,
endMonth?: Date,
) {
let nextMonth = month;
if (startMonth && year === startMonth.getFullYear()) {
nextMonth = Math.max(nextMonth, startMonth.getMonth());
}
if (endMonth && year === endMonth.getFullYear()) {
nextMonth = Math.min(nextMonth, endMonth.getMonth());
}
return Math.min(11, Math.max(0, nextMonth));
}
type CalendarGridPickerContextValue = {
view: CalendarView;
setView: (view: CalendarView) => void;
pickerYear: number;
setPickerYear: React.Dispatch<React.SetStateAction<number>>;
decadeStart: number;
setDecadeStart: React.Dispatch<React.SetStateAction<number>>;
};
const CalendarGridPickerContext =
React.createContext<CalendarGridPickerContextValue | null>(null);
function useCalendarGridPicker() {
const context = React.useContext(CalendarGridPickerContext);
if (!context) {
throw new Error(
'useCalendarGridPicker must be used within CalendarGridPicker',
);
}
return context;
}
function PickerNav({
label,
onPrevious,
onNext,
onLabelClick,
disablePrevious = false,
disableNext = false,
}: {
label: string;
onPrevious: () => void;
onNext: () => void;
onLabelClick?: () => void;
disablePrevious?: boolean;
disableNext?: boolean;
}) {
return (
<div className={PICKER_HEADER_CLASS}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-start')}
onClick={onPrevious}
disabled={disablePrevious}
aria-label="Previous"
>
<ChevronLeftIcon className="size-4" />
</Button>
{onLabelClick ? (
<button
type="button"
className="rounded-lg px-2 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onLabelClick}
>
{label}
</button>
) : (
<span className="px-2 text-center text-sm font-semibold text-foreground">
{label}
</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-end')}
onClick={onNext}
disabled={disableNext}
aria-label="Next"
>
<ChevronRightIcon className="size-4" />
</Button>
</div>
);
}
function MonthGrid({
year,
activeMonth,
onSelect,
startMonth,
endMonth,
}: {
year: number;
activeMonth: number;
onSelect: (month: number) => void;
startMonth?: Date;
endMonth?: Date;
}) {
return (
<div className="grid grid-cols-3 gap-x-2 gap-y-1.5 pt-2">
{MONTH_LABELS.map((label, month) => {
const isDisabled = isMonthOutsideRange(
year,
month,
startMonth,
endMonth,
);
return (
<Button
key={label}
type="button"
variant="ghost"
disabled={isDisabled}
className={cn(
GRID_BUTTON_CLASS,
isDisabled && GRID_BUTTON_DISABLED_CLASS,
activeMonth === month && !isDisabled && GRID_BUTTON_ACTIVE_CLASS,
)}
onClick={() => onSelect(month)}
>
{label}
</Button>
);
})}
</div>
);
}
function YearGrid({
decadeStart,
activeYear,
onSelect,
startMonth,
endMonth,
}: {
decadeStart: number;
activeYear: number;
onSelect: (year: number) => void;
startMonth?: Date;
endMonth?: Date;
}) {
const years = React.useMemo(
() => Array.from({ length: 10 }, (_, index) => decadeStart + index),
[decadeStart],
);
return (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 pt-2">
{years.map((year) => {
const isDisabled = isYearOutsideRange(year, startMonth, endMonth);
return (
<Button
key={year}
type="button"
variant="ghost"
disabled={isDisabled}
className={cn(
GRID_BUTTON_CLASS,
isDisabled && GRID_BUTTON_DISABLED_CLASS,
activeYear === year && !isDisabled && GRID_BUTTON_ACTIVE_CLASS,
)}
onClick={() => onSelect(year)}
>
{year}
</Button>
);
})}
</div>
);
}
function CalendarMonthCaption({
calendarMonth,
className,
children: _children,
displayIndex: _displayIndex,
...props
}: MonthCaptionProps) {
const { goToMonth, previousMonth, nextMonth } = useDayPicker();
const { setView, setPickerYear, setDecadeStart } = useCalendarGridPicker();
const date = calendarMonth.date;
const monthName = date.toLocaleString('default', { month: 'long' });
const year = date.getFullYear();
return (
<div className={cn(PICKER_HEADER_CLASS, className)} {...props}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-start')}
disabled={!previousMonth}
aria-label="Previous month"
onClick={() => previousMonth && goToMonth(previousMonth)}
>
<ChevronLeftIcon className="size-4" />
</Button>
<div className="flex items-center justify-center gap-1 text-sm font-semibold">
<button
type="button"
className="rounded-lg px-1 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
setPickerYear(year);
setView('months');
}}
>
{monthName}
</button>
<button
type="button"
className="rounded-lg px-1 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
setDecadeStart(Math.floor(year / 10) * 10);
setPickerYear(year);
setView('years');
}}
>
{year}
</button>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-end')}
disabled={!nextMonth}
aria-label="Next month"
onClick={() => nextMonth && goToMonth(nextMonth)}
>
<ChevronRightIcon className="size-4" />
</Button>
</div>
);
}
function CalendarGridPickerPanel({
month,
onMonthChange,
startMonth,
endMonth,
}: {
month: Date;
onMonthChange: (month: Date) => void;
startMonth?: Date;
endMonth?: Date;
}) {
const {
view,
setView,
pickerYear,
setPickerYear,
decadeStart,
setDecadeStart,
} = useCalendarGridPicker();
const minYear = startMonth?.getFullYear();
const maxYear = endMonth?.getFullYear();
const activeMonth = clampMonthToRange(
pickerYear,
month.getMonth(),
startMonth,
endMonth,
);
if (view === 'months') {
return (
<div className="w-full">
<PickerNav
label={String(pickerYear)}
onPrevious={() => setPickerYear((year) => year - 1)}
onNext={() => setPickerYear((year) => year + 1)}
onLabelClick={() => {
setDecadeStart(Math.floor(pickerYear / 10) * 10);
setView('years');
}}
disablePrevious={minYear !== undefined && pickerYear <= minYear}
disableNext={maxYear !== undefined && pickerYear >= maxYear}
/>
<MonthGrid
year={pickerYear}
activeMonth={activeMonth}
startMonth={startMonth}
endMonth={endMonth}
onSelect={(nextMonth) => {
onMonthChange(new Date(pickerYear, nextMonth, 1));
setView('days');
}}
/>
</div>
);
}
if (view === 'years') {
return (
<div className="w-full">
<PickerNav
label={`${decadeStart} - ${decadeStart + 9}`}
onPrevious={() => setDecadeStart((start) => start - 10)}
onNext={() => setDecadeStart((start) => start + 10)}
disablePrevious={minYear !== undefined && decadeStart <= minYear}
disableNext={maxYear !== undefined && decadeStart + 10 > maxYear}
/>
<YearGrid
decadeStart={decadeStart}
activeYear={pickerYear}
startMonth={startMonth}
endMonth={endMonth}
onSelect={(year) => {
const nextMonth = clampMonthToRange(
year,
activeMonth,
startMonth,
endMonth,
);
setPickerYear(year);
onMonthChange(new Date(year, nextMonth, 1));
setView('months');
}}
/>
</div>
);
}
return null;
}
export type CalendarGridPickerProps = Omit<
Extract<React.ComponentProps<typeof Calendar>, { mode: 'single' }>,
'captionLayout' | 'navLayout' | 'hideNavigation'
> & {
onViewChange?: (view: CalendarView) => void;
};
function CalendarGridPicker({
className,
classNames,
components,
formatters,
month,
defaultMonth,
onMonthChange,
onViewChange,
startMonth,
endMonth,
...props
}: CalendarGridPickerProps) {
const initialMonth = month ?? defaultMonth ?? new Date();
const [view, setView] = React.useState<CalendarView>('days');
const [pickerYear, setPickerYear] = React.useState(
initialMonth.getFullYear(),
);
const [decadeStart, setDecadeStart] = React.useState(
Math.floor(initialMonth.getFullYear() / 10) * 10,
);
const [internalMonth, setInternalMonth] = React.useState(initialMonth);
const currentMonth = month ?? internalMonth;
React.useEffect(() => {
if (month) {
setPickerYear(month.getFullYear());
setDecadeStart(Math.floor(month.getFullYear() / 10) * 10);
}
}, [month]);
const handleViewChange = React.useCallback(
(nextView: CalendarView) => {
setView(nextView);
onViewChange?.(nextView);
},
[onViewChange],
);
const handleMonthChange = React.useCallback(
(nextMonth: Date) => {
if (!month) setInternalMonth(nextMonth);
setPickerYear(nextMonth.getFullYear());
setDecadeStart(Math.floor(nextMonth.getFullYear() / 10) * 10);
onMonthChange?.(nextMonth);
},
[month, onMonthChange],
);
const contextValue = React.useMemo(
() => ({
view,
setView: handleViewChange,
pickerYear,
setPickerYear,
decadeStart,
setDecadeStart,
}),
[view, handleViewChange, pickerYear, decadeStart],
);
if (view !== 'days') {
return (
<CalendarGridPickerContext.Provider value={contextValue}>
<div className={cn(PICKER_SHELL_CLASS, PICKER_WIDTH, className)}>
<CalendarGridPickerPanel
month={currentMonth}
onMonthChange={handleMonthChange}
startMonth={startMonth}
endMonth={endMonth}
/>
</div>
</CalendarGridPickerContext.Provider>
);
}
return (
<CalendarGridPickerContext.Provider value={contextValue}>
<Calendar
hideNavigation
className={cn(PICKER_SHELL_CLASS, PICKER_WIDTH, className)}
month={currentMonth}
onMonthChange={handleMonthChange}
startMonth={startMonth}
endMonth={endMonth}
formatters={{
formatWeekdayName: (date) =>
date.toLocaleString('en-US', { weekday: 'short' }).slice(0, 2),
...formatters,
}}
classNames={{
month: 'flex w-full flex-col gap-2',
month_caption: 'p-0',
weekdays: 'flex w-full',
weekday:
'flex-1 text-center text-[0.8rem] font-medium text-muted-foreground select-none',
week: 'mt-1 flex w-full',
...classNames,
}}
components={{
MonthCaption: CalendarMonthCaption,
DayButton: (dayButtonProps) => (
<CalendarDayButton
{...dayButtonProps}
className={cn(
dayButtonProps.className,
'data-[selected-single=true]:rounded-full data-[selected-single=true]:bg-muted data-[selected-single=true]:text-foreground',
)}
/>
),
...components,
}}
{...(props as Extract<
React.ComponentProps<typeof Calendar>,
{ mode: 'single' }
>)}
/>
</CalendarGridPickerContext.Provider>
);
}
export { CalendarGridPicker, type CalendarView };

View File

@@ -1,19 +1,16 @@
"use client"
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
)}

View File

@@ -1,84 +1,95 @@
import * as React from 'react';
import * as React from "react"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<'div'>) {
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<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,
};
}

View File

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

View File

@@ -1,11 +1,11 @@
'use client';
"use client"
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...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 }

View File

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

View File

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

View File

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

View File

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