chore: update eslint and prettier configuration

This commit is contained in:
2026-06-17 16:23:28 +05:30
parent b54242cf7e
commit 4c2241ff72
140 changed files with 2270 additions and 1287 deletions

View File

@@ -17,7 +17,9 @@ export default function LoginPage() {
<div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen">
<section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left">
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
<p className="text-sm font-medium text-muted-foreground">
VisionRoad
</p>
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">
Use your email and password to continue.
@@ -39,7 +41,9 @@ export default function LoginPage() {
})}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
@@ -57,7 +61,9 @@ export default function LoginPage() {
})}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
<p className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>

View File

@@ -11,8 +11,8 @@ export default function AccessPage() {
</div>
<h1 className="text-2xl font-semibold">Access denied</h1>
<p className="mt-2 text-sm text-muted-foreground">
You do not have permission to access any available module. Contact your administrator if
you need access.
You do not have permission to access any available module. Contact
your administrator if you need access.
</p>
</div>
</main>

View File

@@ -21,7 +21,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
{
accessorKey: 'name',
header: 'Client',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
),
},
{
accessorKey: 'email',
@@ -33,7 +35,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.contact_name}</p>
<p className="text-xs text-muted-foreground">{row.original.contact_phone_number}</p>
<p className="text-xs text-muted-foreground">
{row.original.contact_phone_number}
</p>
</div>
),
},

View File

@@ -38,8 +38,12 @@ export function ClientSheet({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{clientId ? 'Edit Client' : 'Create Client'}</DialogTitle>
<DialogDescription>Manage company and primary contact details.</DialogDescription>
<DialogTitle>
{clientId ? 'Edit Client' : 'Create Client'}
</DialogTitle>
<DialogDescription>
Manage company and primary contact details.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
@@ -88,7 +92,11 @@ export function ClientSheet({
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="gst">GST</Label>
<Input id="gst" placeholder="27ABCDE1234F1Z5" {...register('gst')} />
<Input
id="gst"
placeholder="27ABCDE1234F1Z5"
{...register('gst')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pan">PAN</Label>
@@ -141,7 +149,9 @@ export function ClientSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{clientId ? 'Update Client' : 'Create Client'}
</Button>
</DialogFooter>

View File

@@ -12,7 +12,8 @@ export function useClientFilters() {
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
const [statusFilter, setStatusFilterValue] =
useState<ClientStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {

View File

@@ -38,7 +38,9 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: clientKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update client' : 'Failed to create client');
toast.error(
values.id ? 'Failed to update client' : 'Failed to create client',
);
},
});
}
@@ -46,7 +48,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
export function useClientStatusMutation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (client: Client) => clientService.updateClientStatus(client.id, !client.is_active),
mutationFn: (client: Client) =>
clientService.updateClientStatus(client.id, !client.is_active),
onSuccess: () => {
toast.success('Client status updated');
queryClient.invalidateQueries({ queryKey: clientKeys.all });

View File

@@ -37,7 +37,8 @@ function buildClientListParams({
export function useClientsQuery(params: UseClientsQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);

View File

@@ -44,7 +44,8 @@ export default function ClientsPage() {
const clientForm = useClientForm({
onSaved: () => setIsSheetOpen(false),
});
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm;
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
clientForm;
const { register, handleSubmit, clientId, isSaving } = clientForm;
const statusMutation = useClientStatusMutation();
const { mutate: updateClientStatus, pendingClientId } = statusMutation;

View File

@@ -30,7 +30,11 @@ import { DashboardMap } from '@/components/dashboard/dashboard-map';
import { PageHeader } from '@/components/page-header';
import { projectService, projectSummaryService } from '@/services/api';
import { Project } from '@/types';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
import { toast } from 'sonner';
@@ -160,7 +164,8 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
chnDrainIssue > 0 ||
chnDefectiveCulvert > 0
) {
const shortName = chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
const shortName =
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
chainageData.push({
name: shortName,
defected_sign_board: chnDefectedSignboard,
@@ -207,8 +212,12 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
export default function DashboardPage() {
const [isLoading, setIsLoading] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
null,
);
const [stats, setStats] = useState<DetectionStats>({
totalDefectedSignboard: 0,
totalPothole: 0,
@@ -230,7 +239,9 @@ export default function DashboardPage() {
setProjects(projectsData.items);
if (projectsData.items.length > 0) {
setSelectedProjectId(projectsData.items[projectsData.items.length - 1].id);
setSelectedProjectId(
projectsData.items[projectsData.items.length - 1].id,
);
}
} catch (err) {
console.error('Failed to load projects:', err);
@@ -258,12 +269,18 @@ export default function DashboardPage() {
: [];
// Extract chainages from selected package
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(null); // Changed 'selectedLocationId' to 'selectedChainageId'
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
null,
);
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(
null,
); // Changed 'selectedLocationId' to 'selectedChainageId'
const chainages =
projectSummary && selectedPackageId && selectedPackageId !== 'all'
? Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {}).map((chnName) => ({
? Object.keys(
projectSummary.packages[selectedPackageId]?.chainages || {},
).map((chnName) => ({
id: chnName,
name: chnName,
}))
@@ -281,9 +298,10 @@ export default function DashboardPage() {
try {
setIsLoading(true);
const summary = await projectSummaryService.getProjectSummary<ProjectSummary>(
selectedProjectId,
);
const summary =
await projectSummaryService.getProjectSummary<ProjectSummary>(
selectedProjectId,
);
setProjectSummary(summary);
} catch (err) {
console.error('Failed to load project summary:', err);
@@ -322,8 +340,15 @@ export default function DashboardPage() {
// Auto-select last chainage when package is selected
useEffect(() => {
if (projectSummary && selectedPackageId && selectedPackageId !== 'all' && selectedChainageId === null) {
const chainageIds = Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {});
if (
projectSummary &&
selectedPackageId &&
selectedPackageId !== 'all' &&
selectedChainageId === null
) {
const chainageIds = Object.keys(
projectSummary.packages[selectedPackageId]?.chainages || {},
);
if (chainageIds.length > 0) {
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
}
@@ -415,7 +440,8 @@ export default function DashboardPage() {
chnDrainIssue > 0 ||
chnDefectiveCulvert > 0
) {
const shortName = chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
const shortName =
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
chainageData.push({
name: shortName,
defected_sign_board: chnDefectedSignboard,
@@ -478,7 +504,10 @@ export default function DashboardPage() {
<span>Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0 overflow-hidden" align="end">
<PopoverContent
className="w-[320px] p-0 overflow-hidden"
align="end"
>
<div className="p-4 border-b bg-muted/30">
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
<Milestone className="h-4 w-4 text-primary" />
@@ -577,7 +606,9 @@ export default function DashboardPage() {
<Card className="flex-1 h-full">
<CardHeader className="items-center pb-0">
<CardTitle>Detection Distribution</CardTitle>
<CardDescription>Breakdown of all detected road conditions</CardDescription>
<CardDescription>
Breakdown of all detected road conditions
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<DetectionDonutChart
@@ -605,13 +636,18 @@ export default function DashboardPage() {
<Reveal delay={0.3} direction="right" className="flex flex-col">
<Card className="flex-1 h-full">
<CardHeader className="items-center pb-0">
<CardTitle className="text-base font-bold">Severity by Segment</CardTitle>
<CardTitle className="text-base font-bold">
Severity by Segment
</CardTitle>
<CardDescription className="text-xs">
Detections grouped by road segment
</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ChainageBarChart data={stats.chainageData || []} isLoading={isLoading} />
<ChainageBarChart
data={stats.chainageData || []}
isLoading={isLoading}
/>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="leading-none text-muted-foreground">

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Package, Project } from '@/types';
function EmptyValue() {
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
@@ -70,14 +72,16 @@ export function usePackageColumns(projects: Project[]) {
accessorKey: 'project_id',
header: 'Project',
cell: ({ row }) =>
projects.find((project) => project.id === row.original.project_id)?.name ||
row.original.project_id,
projects.find((project) => project.id === row.original.project_id)
?.name || row.original.project_id,
},
{
id: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const project = projects.find((item) => item.id === row.original.project_id);
const project = projects.find(
(item) => item.id === row.original.project_id,
);
return <StateBadges value={project?.state ?? null} />;
},
},

View File

@@ -57,14 +57,23 @@ export function PackageDialog({
canSubmit,
isSaving,
}: PackageDialogProps) {
const projectErrorMessage = isSubmitted ? errors.project_id?.message : undefined;
const projectErrorMessage = isSubmitted
? errors.project_id?.message
: undefined;
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startErrorMessage = isSubmitted ? errors.chainage_start_km?.message : undefined;
const endErrorMessage = isSubmitted ? errors.chainage_end_km?.message : undefined;
const startErrorMessage = isSubmitted
? errors.chainage_start_km?.message
: undefined;
const endErrorMessage = isSubmitted
? errors.chainage_end_km?.message
: undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogContent
className="max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
@@ -101,12 +110,22 @@ export function PackageDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
) : null}
<div className="grid gap-5 md:grid-cols-2">
<FormField id="package-name" label="Package Name" required error={nameErrorMessage}>
<FormField
id="package-name"
label="Package Name"
required
error={nameErrorMessage}
>
<Input
id="package-name"
placeholder="e.g. Package 01"
@@ -124,10 +143,18 @@ export function PackageDialog({
</span>
}
>
<Input id="package-region" placeholder="e.g. North Zone" {...register('region')} />
<Input
id="package-region"
placeholder="e.g. North Zone"
{...register('region')}
/>
</FormField>
<FormField id="package-start" label="Segment Start (km)" error={startErrorMessage}>
<FormField
id="package-start"
label="Segment Start (km)"
error={startErrorMessage}
>
<Input
id="package-start"
type="number"
@@ -138,7 +165,11 @@ export function PackageDialog({
/>
</FormField>
<FormField id="package-end" label="Segment End (km)" error={endErrorMessage}>
<FormField
id="package-end"
label="Segment End (km)"
error={endErrorMessage}
>
<Input
id="package-end"
type="number"

View File

@@ -49,7 +49,9 @@ function toNumberOrZero(value: string) {
return value.trim() ? Number(value) : 0;
}
function toPackagePayload(values: PackageFormValues): PackageCreate | PackageUpdate {
function toPackagePayload(
values: PackageFormValues,
): PackageCreate | PackageUpdate {
return {
project_id: values.project_id,
name: values.name.trim(),
@@ -78,7 +80,8 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'id' });
const projectId = useWatch({ control, name: 'project_id' }) || '';
const packageName = useWatch({ control, name: 'name' }) || '';
const canSubmit = projectId.trim().length > 0 && packageName.trim().length > 0;
const canSubmit =
projectId.trim().length > 0 && packageName.trim().length > 0;
const saveMutation = useMutation({
mutationFn: (values: PackageFormValues) => {
@@ -94,7 +97,9 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: packageKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update package' : 'Failed to create package');
toast.error(
values.id ? 'Failed to update package' : 'Failed to create package',
);
},
});
@@ -130,7 +135,11 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
);
const setProjectId = useCallback(
(value: string) => setValue('project_id', value, { shouldDirty: true, shouldValidate: true }),
(value: string) =>
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -15,7 +15,10 @@ interface UsePackagesQueryParams {
}
export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: packageKeys.list(listParams),

View File

@@ -37,7 +37,8 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) => formatPrice(row.original.price, row.original.billing_cycle),
cell: ({ row }) =>
formatPrice(row.original.price, row.original.billing_cycle),
},
{
accessorKey: 'trial_days',
@@ -68,7 +69,9 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null}
{row.original.is_custom ? (
<Badge variant="outline">Custom</Badge>
) : null}
</div>
),
},

View File

@@ -31,7 +31,10 @@ export function PlanFilters({
placeholder="Search plans"
className="w-full sm:w-72"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}
>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>

View File

@@ -201,7 +201,9 @@ export function PlanSheet({
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>Permissions</Label>
<span className="text-muted-foreground">{permissionIds.length} selected</span>
<span className="text-muted-foreground">
{permissionIds.length} selected
</span>
</div>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground">
@@ -226,7 +228,9 @@ export function PlanSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{planId ? 'Update Plan' : 'Create Plan'}
</Button>
</SheetFooter>

View File

@@ -117,7 +117,10 @@ export function usePlanForm({
max_organizations: plan.max_organizations ?? 0,
max_users: plan.max_users ?? 0,
max_roles: plan.max_roles ?? 0,
permission_ids: collectPermissionIdsByKeys(permissionTree, plan.permissions || []),
permission_ids: collectPermissionIdsByKeys(
permissionTree,
plan.permissions || [],
),
is_active: plan.is_active,
is_custom: plan.is_custom,
});
@@ -126,7 +129,11 @@ export function usePlanForm({
);
const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
(ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -19,7 +19,9 @@ export function useSavePlanMutation({ onSaved }: { onSaved: () => void }) {
onSaved();
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update plan' : 'Failed to create plan');
toast.error(
values.id ? 'Failed to update plan' : 'Failed to create plan',
);
},
});
}
@@ -28,7 +30,8 @@ export function usePlanStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (plan: Plan) => planService.updatePlanStatus(plan.id, !plan.is_active),
mutationFn: (plan: Plan) =>
planService.updatePlanStatus(plan.id, !plan.is_active),
onSuccess: () => {
toast.success('Plan status updated');
queryClient.invalidateQueries({ queryKey: planKeys.lists() });

View File

@@ -16,7 +16,10 @@ import { PlanTable } from './components/PlanTable';
import { usePlanFilters } from './hooks/usePlanFilters';
import { usePlanForm } from './hooks/usePlanForm';
import { usePlanStatusMutation } from './hooks/usePlanMutations';
import { useOrganizationPermissionTreeQuery, usePlansQuery } from './hooks/usePlanQueries';
import {
useOrganizationPermissionTreeQuery,
usePlansQuery,
} from './hooks/usePlanQueries';
export default function PlansPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
@@ -57,8 +60,15 @@ export default function PlansPage() {
onSaved: () => handleSheetOpenChange(false),
});
const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm;
const { register, control, onSubmit, planId, permissionIds, setPermissionIds, isSaving } =
planForm;
const {
register,
control,
onSubmit,
planId,
permissionIds,
setPermissionIds,
isSaving,
} = planForm;
const statusMutation = usePlanStatusMutation();
const openCreate = useCallback(() => {

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Project } from '@/types';
function EmptyValue() {
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}

View File

@@ -42,14 +42,21 @@ export function ProjectDialog({
isSaving,
}: ProjectDialogProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startLatErrorMessage = isSubmitted ? errors.start_lat?.message : undefined;
const startLngErrorMessage = isSubmitted ? errors.start_lng?.message : undefined;
const startLatErrorMessage = isSubmitted
? errors.start_lat?.message
: undefined;
const startLngErrorMessage = isSubmitted
? errors.start_lng?.message
: undefined;
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogContent
className="max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
@@ -65,7 +72,12 @@ export function ProjectDialog({
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-6">
<FormField id="project-name" label="Project Name" required error={nameErrorMessage}>
<FormField
id="project-name"
label="Project Name"
required
error={nameErrorMessage}
>
<Input
id="project-name"
placeholder="Enter a descriptive project name"
@@ -76,7 +88,11 @@ export function ProjectDialog({
<div className="grid gap-5 md:grid-cols-2">
<FormField id="project-state" label="State">
<Input id="project-state" placeholder="e.g. Maharashtra" {...register('state')} />
<Input
id="project-state"
placeholder="e.g. Maharashtra"
{...register('state')}
/>
</FormField>
<FormField
id="project-corridor"
@@ -102,7 +118,11 @@ export function ProjectDialog({
START POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="project-start-lat" label="Lat" error={startLatErrorMessage}>
<FormField
id="project-start-lat"
label="Lat"
error={startLatErrorMessage}
>
<Input
id="project-start-lat"
type="number"
@@ -113,7 +133,11 @@ export function ProjectDialog({
{...register('start_lat')}
/>
</FormField>
<FormField id="project-start-lng" label="Lng" error={startLngErrorMessage}>
<FormField
id="project-start-lng"
label="Lng"
error={startLngErrorMessage}
>
<Input
id="project-start-lng"
type="number"
@@ -133,7 +157,11 @@ export function ProjectDialog({
END POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="project-end-lat" label="Lat" error={endLatErrorMessage}>
<FormField
id="project-end-lat"
label="Lat"
error={endLatErrorMessage}
>
<Input
id="project-end-lat"
type="number"
@@ -144,7 +172,11 @@ export function ProjectDialog({
{...register('end_lat')}
/>
</FormField>
<FormField id="project-end-lng" label="Lng" error={endLngErrorMessage}>
<FormField
id="project-end-lng"
label="Lng"
error={endLngErrorMessage}
>
<Input
id="project-end-lng"
type="number"

View File

@@ -53,7 +53,9 @@ function toNullableNumber(value: string) {
return value.trim() ? Number(value) : null;
}
function toProjectPayload(values: ProjectFormValues): ProjectCreate | ProjectUpdate {
function toProjectPayload(
values: ProjectFormValues,
): ProjectCreate | ProjectUpdate {
return {
name: values.name.trim(),
state: trimOptional(values.state),
@@ -98,7 +100,9 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update project' : 'Failed to create project');
toast.error(
values.id ? 'Failed to update project' : 'Failed to create project',
);
},
});

View File

@@ -15,7 +15,10 @@ interface UseProjectsQueryParams {
}
export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: projectKeys.list(listParams),
@@ -36,7 +39,8 @@ export function useDeleteProjectMutation() {
},
onError: () => {
toast.error('Deletion failed', {
description: 'The project could not be removed. Please try again or check your permissions.',
description:
'The project could not be removed. Please try again or check your permissions.',
});
},
});

View File

@@ -12,7 +12,10 @@ import { ProjectTable } from './components/ProjectTable';
import { useProjectColumns } from './components/ProjectColumns';
import { useProjectFilters } from './hooks/useProjectFilters';
import { useProjectForm } from './hooks/useProjectForm';
import { useDeleteProjectMutation, useProjectsQuery } from './hooks/useProjectQueries';
import {
useDeleteProjectMutation,
useProjectsQuery,
} from './hooks/useProjectQueries';
export default function ProjectPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -60,7 +63,9 @@ export default function ProjectPage() {
const deleteProject = useCallback(
(project: Project) => {
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) {
if (
!confirm(`Are you sure you want to delete project "${project.name}"?`)
) {
return;
}
deleteProjectMutation.mutate(project);

View File

@@ -19,8 +19,11 @@ export default function VideoResultsPage() {
const router = useRouter();
const { videoId } = useParams() as { videoId: string };
const [session, setSession] = useState<SessionContext | null>(null);
const [detectionData, setDetectionData] = useState<DetectionData | null>(null);
const [detectionType, setDetectionType] = useState<DetectionType>('pothole-detection');
const [detectionData, setDetectionData] = useState<DetectionData | null>(
null,
);
const [detectionType, setDetectionType] =
useState<DetectionType>('pothole-detection');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -89,7 +92,9 @@ export default function VideoResultsPage() {
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading detection results...</p>
<p className="text-sm text-muted-foreground">
Loading detection results...
</p>
</Card>
</div>
);
@@ -101,7 +106,10 @@ export default function VideoResultsPage() {
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
<p className="text-destructive font-medium">{error}</p>
<div className="flex gap-4">
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline">
<Button
onClick={() => router.push(ROUTES.UPLOAD)}
variant="outline"
>
Back to Upload
</Button>
<Button onClick={handleNewAnalysis}>New Analysis</Button>
@@ -116,7 +124,11 @@ export default function VideoResultsPage() {
<main className="min-h-screen">
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
<div className="mb-8">
<PageHeader title={getTitle()} description={`Video ID: ${videoId}`} icon={TrendingUp} />
<PageHeader
title={getTitle()}
description={`Video ID: ${videoId}`}
icon={TrendingUp}
/>
</div>
{session && (

View File

@@ -55,8 +55,10 @@ export default function ResultsPage() {
};
const getTitle = () => {
if (detectionType === 'pothole-detection') return 'Pothole Detection Results';
if (detectionType === 'sign-board-detection') return 'Signboard Detection Results';
if (detectionType === 'pothole-detection')
return 'Pothole Detection Results';
if (detectionType === 'sign-board-detection')
return 'Signboard Detection Results';
return 'Pothole & Signboard Detection Results';
};

View File

@@ -36,7 +36,9 @@ export function useRoleColumns(): ColumnDef<Role>[] {
accessorKey: 'effective_status',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}>
<Badge
variant={row.original.effective_status ? 'default' : 'secondary'}
>
{row.original.effective_status ? 'Active' : 'Inactive'}
</Badge>
),

View File

@@ -31,7 +31,10 @@ export function RoleFilters({
onChange={onSearchChange}
placeholder="Search roles"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as StatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>

View File

@@ -54,7 +54,9 @@ export function RoleSheet({
isSaving,
}: RoleSheetProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const displayNameErrorMessage = isSubmitted ? errors.display_name?.message : undefined;
const displayNameErrorMessage = isSubmitted
? errors.display_name?.message
: undefined;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -72,7 +74,12 @@ export function RoleSheet({
<form 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 id="role-name" label="Role Name" required error={nameErrorMessage}>
<FormField
id="role-name"
label="Role Name"
required
error={nameErrorMessage}
>
<Input
id="role-name"
placeholder="Enter role name"
@@ -110,7 +117,9 @@ export function RoleSheet({
error={isSubmitted ? errors.permission_ids?.message : undefined}
className="space-y-3"
labelEnd={
<span className="text-muted-foreground">{permissionIds.length} selected</span>
<span className="text-muted-foreground">
{permissionIds.length} selected
</span>
}
>
{isPermissionsLoading ? (
@@ -129,7 +138,9 @@ export function RoleSheet({
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{roleId ? 'Update Role' : 'Create Role'}
</Button>
<SheetClose asChild>

View File

@@ -61,7 +61,9 @@ export function useRoleForm({
const roleName = useWatch({ control, name: 'name' }) || '';
const displayName = useWatch({ control, name: 'display_name' }) || '';
const canSubmit =
roleName.trim().length > 0 && displayName.trim().length > 0 && permissionIds.length > 0;
roleName.trim().length > 0 &&
displayName.trim().length > 0 &&
permissionIds.length > 0;
const saveMutation = useMutation({
mutationFn: (values: RoleFormValues) =>
@@ -81,7 +83,9 @@ export function useRoleForm({
queryClient.invalidateQueries({ queryKey: roleKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update role' : 'Failed to create role');
toast.error(
values.id ? 'Failed to update role' : 'Failed to create role',
);
},
});
@@ -116,14 +120,21 @@ export function useRoleForm({
name: role.name || '',
display_name: role.display_name || '',
description: role.description || '',
permission_ids: collectPermissionIdsByKeys(permissionTree, role.permissions || []),
permission_ids: collectPermissionIdsByKeys(
permissionTree,
role.permissions || [],
),
});
},
[permissionTree, reset],
);
const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
(ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -32,7 +32,8 @@ function buildRoleListParams({
skip,
limit,
search_term: searchTerm || undefined,
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active',
effective_status:
statusFilter === 'all' ? undefined : statusFilter === 'active',
...getServerSortParams(sorting),
};
}
@@ -40,7 +41,8 @@ function buildRoleListParams({
export function useRolesQuery(params: UseRolesQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);
@@ -71,7 +73,8 @@ export function useRoleStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status),
mutationFn: (role: Role) =>
roleService.updateRoleStatus(role.id, !role.effective_status),
onSuccess: () => {
toast.success('Role status updated');
queryClient.invalidateQueries({ queryKey: roleKeys.all });

View File

@@ -23,7 +23,9 @@ import {
} from './hooks/useRoleQueries';
export default function RolesPage() {
const organizationId = useAppStore((state) => state.user?.organization_id ?? null);
const organizationId = useAppStore(
(state) => state.user?.organization_id ?? null,
);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const {
skip,
@@ -66,7 +68,8 @@ export default function RolesPage() {
isSaving,
} = roleForm;
const statusMutation = useRoleStatusMutation();
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation;
const { mutate: updateRoleStatus, isPending: isStatusPending } =
statusMutation;
const openCreate = useCallback(() => {
prepareCreateRole();

View File

@@ -2,10 +2,19 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDownCircle, ArrowUpCircle, MapPin, Milestone } from 'lucide-react';
import {
ArrowDownCircle,
ArrowUpCircle,
MapPin,
Milestone,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Chainage, Package, Project } from '@/types';
function EmptyValue() {
@@ -43,9 +52,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
@@ -78,7 +85,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project',
header: 'Project',
cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id);
const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id);
return project?.name || <EmptyValue />;
},
@@ -87,7 +96,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id);
const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id);
return <StateBadges value={project?.state ?? null} />;
},
@@ -127,7 +138,8 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
>
<MapPin className="size-3" />
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
{row.original.start_lat.toFixed(4)},{' '}
{row.original.start_lng.toFixed(4)}
</Badge>
),
},

View File

@@ -95,7 +95,11 @@ export function SegmentDialog({
<form onSubmit={onSubmit} className="space-y-6">
{!segmentId ? (
<div className="grid gap-5 md:grid-cols-2">
<FormField label="Project" required error={getError('project_id')}>
<FormField
label="Project"
required
error={getError('project_id')}
>
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!getError('project_id')}>
{isProjectsLoading ? (
@@ -115,10 +119,19 @@ export function SegmentDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
<FormField label="Package" required error={getError('package_id')}>
<FormField
label="Package"
required
error={getError('package_id')}
>
<Select
value={packageId}
onValueChange={onPackageChange}
@@ -132,7 +145,11 @@ export function SegmentDialog({
</span>
) : (
<SelectValue
placeholder={projectId ? 'Choose a package' : 'Select project first'}
placeholder={
projectId
? 'Choose a package'
: 'Select project first'
}
/>
)}
</SelectTrigger>
@@ -144,7 +161,12 @@ export function SegmentDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('package_id')} value={packageId} readOnly />
<input
type="hidden"
{...register('package_id')}
value={packageId}
readOnly
/>
</FormField>
</div>
) : null}
@@ -218,7 +240,12 @@ export function SegmentDialog({
START COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="start-lat" label="Latitude" required error={getError('start_lat')}>
<FormField
id="start-lat"
label="Latitude"
required
error={getError('start_lat')}
>
<Input
id="start-lat"
type="number"
@@ -230,7 +257,12 @@ export function SegmentDialog({
{...register('start_lat')}
/>
</FormField>
<FormField id="start-lng" label="Longitude" required error={getError('start_lng')}>
<FormField
id="start-lng"
label="Longitude"
required
error={getError('start_lng')}
>
<Input
id="start-lng"
type="number"
@@ -251,7 +283,12 @@ export function SegmentDialog({
END COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="end-lat" label="Latitude" required error={getError('end_lat')}>
<FormField
id="end-lat"
label="Latitude"
required
error={getError('end_lat')}
>
<Input
id="end-lat"
type="number"
@@ -263,7 +300,12 @@ export function SegmentDialog({
{...register('end_lat')}
/>
</FormField>
<FormField id="end-lng" label="Longitude" required error={getError('end_lng')}>
<FormField
id="end-lng"
label="Longitude"
required
error={getError('end_lng')}
>
<Input
id="end-lng"
type="number"

View File

@@ -17,7 +17,9 @@ const requiredNumber = (message: string) =>
.string()
.trim()
.min(1, message)
.refine((value) => Number.isFinite(Number(value)), { message: 'Enter a valid number' });
.refine((value) => Number.isFinite(Number(value)), {
message: 'Enter a valid number',
});
const latitude = requiredNumber('Latitude is required').refine(
(value) => {
@@ -64,7 +66,9 @@ const defaultValues: SegmentFormValues = {
direction: 'UP',
};
function toSegmentPayload(values: SegmentFormValues): ChainageCreate | ChainageUpdate {
function toSegmentPayload(
values: SegmentFormValues,
): ChainageCreate | ChainageUpdate {
return {
package_id: values.package_id,
segment_name: values.segment_name.trim(),
@@ -99,7 +103,8 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'package_id' }) || '';
const direction = useWatch({ control, name: 'direction' }) || 'UP';
const segmentName = useWatch({ control, name: 'segment_name' }) || '';
const chainageStartKm = useWatch({ control, name: 'chainage_start_km' }) || '';
const chainageStartKm =
useWatch({ control, name: 'chainage_start_km' }) || '';
const chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || '';
const startLat = useWatch({ control, name: 'start_lat' }) || '';
const startLng = useWatch({ control, name: 'start_lng' }) || '';
@@ -130,14 +135,18 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update segment' : 'Failed to create segment');
toast.error(
values.id ? 'Failed to update segment' : 'Failed to create segment',
);
},
});
const onSubmit = handleSubmit(
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find((error) => error?.message)?.message;
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (firstMessage) {
toast.error(String(firstMessage));
}
@@ -169,14 +178,21 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const setProjectId = useCallback(
(value: string) => {
setValue('project_id', value, { shouldDirty: true, shouldValidate: true });
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
});
setValue('package_id', '', { shouldDirty: true, shouldValidate: true });
},
[setValue],
);
const setPackageId = useCallback(
(value: string) => setValue('package_id', value, { shouldDirty: true, shouldValidate: true }),
(value: string) =>
setValue('package_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -4,7 +4,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { chainageService, packageService, projectService } from '@/services/api';
import {
chainageService,
packageService,
projectService,
} from '@/services/api';
import type { Chainage, PaginationParams } from '@/types';
import { segmentKeys } from '../queries/segmentKeys';
@@ -15,7 +19,10 @@ interface UseSegmentsQueryParams {
}
export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: segmentKeys.list(listParams),
@@ -40,7 +47,8 @@ export function useAllPackageOptionsQuery() {
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
return useQuery({
queryKey: ['packages', 'by-project', projectId],
queryFn: () => packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
queryFn: () =>
packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
enabled: enabled && Boolean(projectId),
});
}
@@ -49,7 +57,8 @@ export function useDeleteSegmentMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (segment: Chainage) => chainageService.deleteChainage(segment.id),
mutationFn: (segment: Chainage) =>
chainageService.deleteChainage(segment.id),
onSuccess: (_data, segment) => {
toast.success('Segment deleted', {
description: `${segment.segment_name} has been removed from the system.`,

View File

@@ -48,7 +48,10 @@ export default function SegmentPage() {
canSubmit,
isSaving,
} = segmentForm;
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen);
const projectPackagesQuery = usePackagesByProjectQuery(
projectId,
isDialogOpen,
);
const openCreate = useCallback(() => {
prepareCreateSegment();
@@ -57,7 +60,9 @@ export default function SegmentPage() {
const openEdit = useCallback(
(segment: Chainage) => {
const pkg = allPackagesQuery.data?.items.find((item) => item.id === segment.package_id);
const pkg = allPackagesQuery.data?.items.find(
(item) => item.id === segment.package_id,
);
prepareEditSegment(segment, pkg?.project_id || '');
setIsDialogOpen(true);
},
@@ -76,7 +81,11 @@ export default function SegmentPage() {
const deleteSegment = useCallback(
(segment: Chainage) => {
if (!confirm(`Are you sure you want to delete segment "${segment.segment_name}"?`)) {
if (
!confirm(
`Are you sure you want to delete segment "${segment.segment_name}"?`,
)
) {
return;
}
deleteSegmentMutation.mutate(segment);

View File

@@ -67,15 +67,20 @@ export function TenantSheet({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle>
<DialogTitle>
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle>
<DialogDescription>
Bind a client and subscription plan, then invite the tenant administrator.
Bind a client and subscription plan, then invite the tenant
administrator.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="space-y-1">
<p>Basic Information</p>
<p className="text-muted-foreground">Tenant identity and subscription binding.</p>
<p className="text-muted-foreground">
Tenant identity and subscription binding.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -106,10 +111,16 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Client</Label>
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
<Select
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'}
placeholder={
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
/>
</SelectTrigger>
<SelectContent>
@@ -123,10 +134,16 @@ export function TenantSheet({
</div>
<div className="space-y-2">
<Label>Subscription Plan</Label>
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
<Select
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'}
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
/>
</SelectTrigger>
<SelectContent>
@@ -143,7 +160,11 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-domain">Domain</Label>
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} />
<Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="tenant-description">Description</Label>
@@ -226,7 +247,9 @@ export function TenantSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{tenantId ? 'Update Tenant' : 'Create Tenant'}
</Button>
</DialogFooter>

View File

@@ -12,7 +12,8 @@ export function useTenantFilters() {
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
const [statusFilter, setStatusFilterValue] =
useState<TenantStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {

View File

@@ -82,7 +82,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
}
if (!isEditMode) {
if (!values.admin_first_name.trim() || !values.admin_last_name.trim() || !values.admin_email.trim()) {
if (
!values.admin_first_name.trim() ||
!values.admin_last_name.trim() ||
!values.admin_email.trim()
) {
toast.error('Complete all required admin fields');
return;
}

View File

@@ -58,7 +58,9 @@ export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
onSaved();
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update tenant' : 'Failed to create tenant');
toast.error(
values.id ? 'Failed to update tenant' : 'Failed to create tenant',
);
},
});
}

View File

@@ -122,7 +122,8 @@ export default function TenantsPage() {
const total = tenantsQuery.data?.total ?? 0;
const clients = clientsLookupQuery.data?.items ?? [];
const plans = plansLookupQuery.data?.items ?? [];
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const isLookupsLoading =
clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const toolbar = useMemo(
() => (
@@ -167,7 +168,9 @@ export default function TenantsPage() {
onSortingChange={setSorting}
onEdit={openEdit}
onDelete={handleDelete}
pendingDeleteId={deleteMutation.isPending ? deleteMutation.variables : undefined}
pendingDeleteId={
deleteMutation.isPending ? deleteMutation.variables : undefined
}
/>
</main>

View File

@@ -11,7 +11,10 @@ import { ROUTES } from '@/utils/routes';
import { Button } from '@/components/ui/button';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(
/^http:\/\//,
'ws://',
);
export default function VideoProcessingPage() {
const router = useRouter();
@@ -40,12 +43,12 @@ export default function VideoProcessingPage() {
setProgress(data.progress || 0);
let message = data.message || 'Processing...';
const uniqueCount =
data.unique_potholes ??
data.unique_pothole ??
data.unique_signboards ??
data.unique_signboard ??
data.unique_culverts ??
data.unique_culvert ??
data.unique_potholes ??
data.unique_pothole ??
data.unique_signboards ??
data.unique_signboard ??
data.unique_culverts ??
data.unique_culvert ??
data.unique_drain_issue;
if (uniqueCount !== undefined) {
message += ` | Unique: ${uniqueCount} | Total: ${data.total_detections || 0}`;
@@ -101,7 +104,9 @@ export default function VideoProcessingPage() {
}
if (statusData.status === 'error') {
setError(statusData.message || 'An error occurred during processing.');
setError(
statusData.message || 'An error occurred during processing.',
);
setIsLoading(false);
return;
}
@@ -203,7 +208,9 @@ export default function VideoProcessingPage() {
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold tracking-tight">Uploading ...</h2>
<h2 className="text-3xl font-bold tracking-tight">
Uploading ...
</h2>
<p className="text-sm font-mono text-muted-foreground tracking-widest">
ID: {videoId}
</p>
@@ -214,7 +221,9 @@ export default function VideoProcessingPage() {
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">
Progress
</span>
<span className="font-bold text-primary text-lg">{progress}%</span>
<span className="font-bold text-primary text-lg">
{progress}%
</span>
</div>
<div className="h-4 rounded-full bg-secondary overflow-hidden border">
<div
@@ -233,7 +242,11 @@ export default function VideoProcessingPage() {
{error && (
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
<p className="text-destructive font-medium">{error}</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
<Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
>
Retry Connection
</Button>
</div>

View File

@@ -2,7 +2,13 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -49,17 +55,20 @@ export default function UploadPage() {
// Load session on mount
useEffect(() => {
// We don't load session on mount for the upload page to ensure
// We don't load session on mount for the upload page to ensure
// project details are filled manually as requested.
setIsLoading(false);
}, []);
const handleSelectionChange = useCallback((selectedSession: SessionContext | null) => {
setSession(selectedSession);
if (selectedSession) {
sessionService.saveSession(selectedSession);
}
}, []);
const handleSelectionChange = useCallback(
(selectedSession: SessionContext | null) => {
setSession(selectedSession);
if (selectedSession) {
sessionService.saveSession(selectedSession);
}
},
[],
);
const handleUpload = async () => {
if (!file) {
@@ -103,7 +112,8 @@ export default function UploadPage() {
} catch (err) {
let errorMessage = 'Upload failed';
if (err instanceof TypeError && err.message === 'Failed to fetch') {
errorMessage = 'Cannot connect to server. Please check if backend is running.';
errorMessage =
'Cannot connect to server. Please check if backend is running.';
} else if (err instanceof Error) {
errorMessage = err.message;
}
@@ -120,7 +130,9 @@ export default function UploadPage() {
);
}
const isSessionComplete = !!(session && sessionService.isSessionValid(session));
const isSessionComplete = !!(
session && sessionService.isSessionValid(session)
);
const isFormValid = isSessionComplete && file && jsonFile;
return (
@@ -140,14 +152,16 @@ export default function UploadPage() {
<Reveal direction="up" delay={0.1}>
<Card className="border">
<CardHeader>
<CardTitle className="text-xl font-bold">1. Project Details</CardTitle>
<CardTitle className="text-xl font-bold">
1. Project Details
</CardTitle>
<CardDescription>
Select the target project, package, and segment.
</CardDescription>
</CardHeader>
<CardContent>
<ProjectSelectionSection
onSelectionChange={handleSelectionChange}
<ProjectSelectionSection
onSelectionChange={handleSelectionChange}
asStep={true}
hideButton={true}
/>
@@ -157,7 +171,13 @@ export default function UploadPage() {
{/* Module 2: Upload Data */}
<Reveal direction="up" delay={0.2}>
<Card className={cn("border transition-all duration-300", !isSessionComplete && "opacity-60 pointer-events-none grayscale-[0.5]")}>
<Card
className={cn(
'border transition-all duration-300',
!isSessionComplete &&
'opacity-60 pointer-events-none grayscale-[0.5]',
)}
>
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center gap-2">
2. Upload Video Data
@@ -170,7 +190,10 @@ export default function UploadPage() {
<div className="grid grid-cols-1 gap-6 text-sm">
{/* Video File Input */}
<div className="space-y-2">
<Label htmlFor="video-file" className="text-sm font-semibold">
<Label
htmlFor="video-file"
className="text-sm font-semibold"
>
Video File <span className="text-destructive">*</span>
</Label>
<div className="relative group">
@@ -191,8 +214,12 @@ export default function UploadPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* JSON File Input */}
<div className="space-y-2">
<Label htmlFor="json-file" className="text-sm font-semibold">
GPS JSON File <span className="text-destructive">*</span>
<Label
htmlFor="json-file"
className="text-sm font-semibold"
>
GPS JSON File{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="json-file"
@@ -210,7 +237,10 @@ export default function UploadPage() {
{/* Select Method */}
<div className="space-y-2">
<Label htmlFor="select-method" className="text-sm font-semibold">
<Label
htmlFor="select-method"
className="text-sm font-semibold"
>
Analysis Method
</Label>
<Select
@@ -218,7 +248,10 @@ export default function UploadPage() {
onValueChange={setSelectMethod}
disabled={uploading || !isSessionComplete}
>
<SelectTrigger id="select-method" className="h-11 bg-muted/20">
<SelectTrigger
id="select-method"
className="h-11 bg-muted/20"
>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>

View File

@@ -23,8 +23,9 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
header: 'Name',
cell: ({ row }) => (
<span>
{[row.original.first_name, row.original.last_name].filter(Boolean).join(' ') ||
row.original.username}
{[row.original.first_name, row.original.last_name]
.filter(Boolean)
.join(' ') || row.original.username}
</span>
),
},
@@ -64,7 +65,13 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
accessorKey: 'effective_status',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.effective_status === 'active' ? 'default' : 'secondary'}>
<Badge
variant={
row.original.effective_status === 'active'
? 'default'
: 'secondary'
}
>
{row.original.effective_status}
</Badge>
),

View File

@@ -30,7 +30,10 @@ export function UserFilters({
onChange={onSearchChange}
placeholder="Search users"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as StatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>

View File

@@ -46,10 +46,16 @@ export function UserSheet({
isSaving,
}: UserSheetProps) {
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
const firstNameErrorMessage = isSubmitted ? errors.first_name?.message : undefined;
const lastNameErrorMessage = isSubmitted ? errors.last_name?.message : undefined;
const firstNameErrorMessage = isSubmitted
? errors.first_name?.message
: undefined;
const lastNameErrorMessage = isSubmitted
? errors.last_name?.message
: undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const phoneErrorMessage = isSubmitted ? errors.phone_number?.message : undefined;
const phoneErrorMessage = isSubmitted
? errors.phone_number?.message
: undefined;
const roleErrorMessage = isSubmitted ? errors.role_id?.message : undefined;
return (
@@ -63,7 +69,12 @@ export function UserSheet({
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField id="first-name" label="First Name" required error={firstNameErrorMessage}>
<FormField
id="first-name"
label="First Name"
required
error={firstNameErrorMessage}
>
<Input
id="first-name"
placeholder="Enter first name"
@@ -72,7 +83,12 @@ export function UserSheet({
/>
</FormField>
<FormField id="last-name" label="Last Name" required error={lastNameErrorMessage}>
<FormField
id="last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
id="last-name"
placeholder="Enter last name"
@@ -82,7 +98,12 @@ export function UserSheet({
</FormField>
</div>
<FormField id="email" label="Email" required error={emailErrorMessage}>
<FormField
id="email"
label="Email"
required
error={emailErrorMessage}
>
<Input
id="email"
placeholder="name@example.com"
@@ -91,7 +112,11 @@ export function UserSheet({
/>
</FormField>
<FormField id="phone-number" label="Phone Number" error={phoneErrorMessage}>
<FormField
id="phone-number"
label="Phone Number"
error={phoneErrorMessage}
>
<Input
id="phone-number"
placeholder="+919876543210"
@@ -111,7 +136,12 @@ export function UserSheet({
<div ref={roleComboboxPortalRef} />
<input type="hidden" {...register('role_id')} value={roleId} readOnly />
<input
type="hidden"
{...register('role_id')}
value={roleId}
readOnly
/>
</FormField>
<DialogFooter className="px-0">
@@ -125,7 +155,9 @@ export function UserSheet({
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{userId ? 'Update User' : 'Create User'}
</Button>

View File

@@ -7,7 +7,12 @@ interface UserStatsProps {
pendingCount: number;
}
export function UserStats({ total, activeCount, inactiveCount, pendingCount }: UserStatsProps) {
export function UserStats({
total,
activeCount,
inactiveCount,
pendingCount,
}: UserStatsProps) {
const stats = [
{ label: 'Total', value: total },
{ label: 'Active', value: activeCount },

View File

@@ -57,10 +57,12 @@ export function UserTable({
onClick: onEdit,
},
{
label: (user) => (user.effective_status === 'active' ? 'Deactivate' : 'Activate'),
label: (user) =>
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.USER.DELETE,
disabled: (user) => pendingUserId === user.id || user.effective_status === 'pending',
disabled: (user) =>
pendingUserId === user.id || user.effective_status === 'pending',
onClick: onToggleStatus,
},
]}

View File

@@ -63,7 +63,8 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
const email = useWatch({ control, name: 'email' }) || '';
const phoneNumber = useWatch({ control, name: 'phone_number' }) || '';
const isPhoneValid =
phoneNumber.trim() === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
phoneNumber.trim() === '' ||
/^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
const canSubmit =
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&

View File

@@ -31,7 +31,9 @@ export function useSaveUserMutation({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: userKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update user' : 'Failed to create user');
toast.error(
values.id ? 'Failed to update user' : 'Failed to create user',
);
},
});
}
@@ -40,7 +42,10 @@ export function useUserStatusMutation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (user: AdministrationUser) =>
userService.updateUserStatus(user.id, getNextStatus(user.effective_status)),
userService.updateUserStatus(
user.id,
getNextStatus(user.effective_status),
),
onSuccess: () => {
toast.success('User status updated');
queryClient.invalidateQueries({ queryKey: userKeys.all });

View File

@@ -37,7 +37,8 @@ function buildUserListParams({
export function useUsersQuery(params: UseUsersQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);

View File

@@ -17,8 +17,11 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
title: {
default: 'VisionRoad',
template: '%s | VisionRoad',
},
description: 'Road infrastructure monitoring and pothole detection platform',
};
export default function RootLayout({
@@ -28,7 +31,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"

View File

@@ -44,7 +44,9 @@ export function BreadcrumbBasic() {
{isLast ? (
<BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>
) : (
<BreadcrumbLink href={href}>{formatSegment(segment)}</BreadcrumbLink>
<BreadcrumbLink href={href}>
{formatSegment(segment)}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}

View File

@@ -2,9 +2,17 @@
import * as React from 'react';
import { ChevronRight, Package } from 'lucide-react';
import { NavUser } from '@/components/nav-user';
import { filterMenuItems, menuItems, type MenuItem } from '@/config/menu.config';
import {
filterMenuItems,
menuItems,
type MenuItem,
} from '@/config/menu.config';
import { usePermissions } from '@/hooks/usePermissions';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Sidebar,
SidebarContent,
@@ -30,7 +38,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const authUser = useAppStore((state) => state.user);
const navItems = filterMenuItems(menuItems, hasPermission);
const user = {
name: [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || 'Admin',
name:
[authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') ||
'Admin',
email: authUser?.email || '',
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
};
@@ -52,7 +62,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarGroup>
<SidebarMenu>
{navItems.map((item) => (
<SidebarNavItem key={item.title} item={item} pathname={pathname} />
<SidebarNavItem
key={item.title}
item={item}
pathname={pathname}
/>
))}
</SidebarMenu>
</SidebarGroup>
@@ -65,14 +79,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
);
}
function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }) {
function SidebarNavItem({
item,
pathname,
}: {
item: MenuItem;
pathname: string;
}) {
const isActive = item.path ? pathname === item.path : false;
const isChildActive = item.children?.some((child) => child.path === pathname) ?? false;
const isChildActive =
item.children?.some((child) => child.path === pathname) ?? false;
const hasChildren = Boolean(item.children?.length);
if (hasChildren) {
return (
<Collapsible asChild defaultOpen={isChildActive} className="group/collapsible">
<Collapsible
asChild
defaultOpen={isChildActive}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
@@ -86,7 +111,10 @@ function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }
{item.children?.map((child) => (
<SidebarMenuSubItem key={child.title}>
{child.path ? (
<SidebarMenuSubButton asChild isActive={pathname === child.path}>
<SidebarMenuSubButton
asChild
isActive={pathname === child.path}
>
<Link href={child.path}>
<child.icon />
<span>{child.title}</span>

View File

@@ -15,7 +15,10 @@ import {
ComboboxValue,
} from '@/components/ui/combobox';
import type { AsyncComboboxOption, AsyncComboboxProps } from './AsyncCombobox.types';
import type {
AsyncComboboxOption,
AsyncComboboxProps,
} from './AsyncCombobox.types';
import { VirtualComboboxList } from './VirtualComboboxList';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
@@ -108,7 +111,9 @@ export function AsyncCombobox({
filter={null}
disabled={isDisabled}
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(item, currentValue) => item.value === currentValue.value}
isItemEqualToValue={(item, currentValue) =>
item.value === currentValue.value
}
onItemHighlighted={handleItemHighlighted}
>
<ComboboxTrigger
@@ -131,7 +136,11 @@ export function AsyncCombobox({
}
/>
<ComboboxContent container={portalContainer}>
<ComboboxInput showTrigger={false} placeholder={searchPlaceholder} disabled={isDisabled} />
<ComboboxInput
showTrigger={false}
placeholder={searchPlaceholder}
disabled={isDisabled}
/>
{isError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage || 'Unable to load options.'}

View File

@@ -1,6 +1,12 @@
'use client';
import { useCallback, useEffect, useImperativeHandle, useRef, type RefObject } from 'react';
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
type RefObject,
} from 'react';
import { Combobox } from '@base-ui/react/combobox';
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import { Loader2 } from 'lucide-react';
@@ -72,7 +78,13 @@ export function VirtualComboboxList({
) {
onLoadMore();
}
}, [filteredItems.length, hasNextPage, isFetchingNextPage, lastVirtualIndex, onLoadMore]);
}, [
filteredItems.length,
hasNextPage,
isFetchingNextPage,
lastVirtualIndex,
onLoadMore,
]);
if (!filteredItems.length && !hasNextPage) {
return null;

View File

@@ -5,7 +5,10 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';
import type { AsyncComboboxOption, PaginatedLookupConfig } from './AsyncCombobox.types';
import type {
AsyncComboboxOption,
PaginatedLookupConfig,
} from './AsyncCombobox.types';
export function usePaginatedLookup<T>({
enabled = true,
@@ -35,7 +38,10 @@ export function usePaginatedLookup<T>({
return undefined;
}
const loadedCount = allPages.reduce((count, page) => count + page.items.length, 0);
const loadedCount = allPages.reduce(
(count, page) => count + page.items.length,
0,
);
return loadedCount < lastPage.total ? loadedCount : undefined;
},
enabled,
@@ -67,7 +73,11 @@ export function usePaginatedLookup<T>({
const item = await resolveSelected(selectedValue);
return item ? mapOption(item) : null;
},
enabled: enabled && Boolean(selectedValue) && !selectedInList && Boolean(resolveSelected),
enabled:
enabled &&
Boolean(selectedValue) &&
!selectedInList &&
Boolean(resolveSelected),
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});

View File

@@ -55,7 +55,10 @@ const chartConfig = {
},
} satisfies ChartConfig;
export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartProps) {
export function ChainageBarChart({
data,
isLoading = false,
}: ChainageBarChartProps) {
if (isLoading) {
return (
<div className="h-[250px] flex items-center justify-center">
@@ -68,7 +71,9 @@ export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartPr
return (
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
<p className="text-sm">No segment data available</p>
<p className="text-xs mt-1">Process videos to see detections by segment</p>
<p className="text-xs mt-1">
Process videos to see detections by segment
</p>
</div>
);
}
@@ -94,17 +99,43 @@ export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartPr
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => (value.length > 8 ? `${value.slice(0, 8)}...` : value)}
tickFormatter={(value) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
fontSize={12}
/>
<YAxis tickLine={false} axisLine={false} fontSize={12} tickMargin={10} />
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dashed" />} />
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
tickMargin={10}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dashed" />}
/>
<Bar dataKey="pothole" fill="var(--color-pothole)" radius={4} />
<Bar dataKey="defected_sign_board" fill="var(--color-defected_sign_board)" radius={4} />
<Bar
dataKey="defected_sign_board"
fill="var(--color-defected_sign_board)"
radius={4}
/>
<Bar dataKey="road_crack" fill="var(--color-road_crack)" radius={4} />
<Bar dataKey="damaged_road_marking" fill="var(--color-damaged_road_marking)" radius={4} />
<Bar dataKey="drain_issue" fill="var(--color-drain_issue)" radius={4} />
<Bar dataKey="defective_culvert" fill="var(--color-defective_culvert)" radius={4} />
<Bar
dataKey="damaged_road_marking"
fill="var(--color-damaged_road_marking)"
radius={4}
/>
<Bar
dataKey="drain_issue"
fill="var(--color-drain_issue)"
radius={4}
/>
<Bar
dataKey="defective_culvert"
fill="var(--color-defective_culvert)"
radius={4}
/>
</BarChart>
</ChartContainer>
</div>

View File

@@ -1,7 +1,13 @@
'use client';
import { useEffect } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import {
MapContainer,
TileLayer,
CircleMarker,
Popup,
useMap,
} from 'react-leaflet';
import { LatLngBounds, LatLng } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Detection } from '@/types';
@@ -24,7 +30,9 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
return null;
}
export default function DashboardMapContent({ detections }: DashboardMapContentProps) {
export default function DashboardMapContent({
detections,
}: DashboardMapContentProps) {
if (detections.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center bg-muted/20">
@@ -56,9 +64,9 @@ export default function DashboardMapContent({ detections }: DashboardMapContentP
const getMarkerColor = (typeId: string) => {
const config = DETECTION_TYPES[typeId.toLowerCase()];
if (config) {
return {
fill: config.color,
stroke: '#ffffff' // White stroke for better visibility on the map
return {
fill: config.color,
stroke: '#ffffff', // White stroke for better visibility on the map
};
}
return { fill: '#64748b', stroke: '#475569' }; // Default Slate
@@ -104,28 +112,44 @@ export default function DashboardMapContent({ detections }: DashboardMapContentP
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none">
{typeName}
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Class</span>
<span className="font-medium text-right capitalize">{detection.class.replace(/_/g, ' ')}</span>
<span className="font-medium text-right capitalize">
{detection.class.replace(/_/g, ' ')}
</span>
</div>
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Confidence</span>
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
<span className="text-muted-foreground text-xs">
Confidence
</span>
<span className="font-medium text-right">
{(detection.confidence * 100).toFixed(1)}%
</span>
</div>
</div>
<div className="pt-2 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">Location Details</div>
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
Location Details
</div>
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
<span className="font-medium tracking-tighter">{detection.latitude!.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Latitude
</span>
<span className="font-medium tracking-tighter">
{detection.latitude!.toFixed(6)}
</span>
</div>
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
<span className="font-medium tracking-tighter">{detection.longitude!.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Longitude
</span>
<span className="font-medium tracking-tighter">
{detection.longitude!.toFixed(6)}
</span>
</div>
</div>
</div>

View File

@@ -59,18 +59,23 @@ export function DashboardMap({
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
if (!pkg) continue;
const chainagesToProcess =
selectedChainageId && selectedChainageId !== 'all'
? { [selectedChainageId]: (pkg as any).chainages?.[selectedChainageId] }
? {
[selectedChainageId]: (pkg as any).chainages?.[
selectedChainageId
],
}
: (pkg as any).chainages || {};
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
if (!chn) continue;
const chainageDetections = ((chn as any).detections || []).filter(
(d: any) => (d.type || d.class || '').toLowerCase() !== 'good_sign_board'
);
filteredDetections.push(...chainageDetections);
const chainageDetections = ((chn as any).detections || []).filter(
(d: any) =>
(d.type || d.class || '').toLowerCase() !== 'good_sign_board',
);
filteredDetections.push(...chainageDetections);
}
}
@@ -99,16 +104,18 @@ export function DashboardMap({
const validDetections = detections.filter((d) => {
const hasGps = d.latitude && d.longitude;
if (!hasGps) return false;
if (selectedTypes.length === 0) return true;
const typeId = (d.type || d.class || '').toLowerCase();
return selectedTypes.includes(typeId);
});
return (
<Card
<Card
className={`overflow-hidden transition-all duration-300 p-2 rounded-lg ${
isMaximized ? 'fixed inset-0 z-100 m-0 rounded-none bg-background' : className
isMaximized
? 'fixed inset-0 z-100 m-0 rounded-none bg-background'
: className
}`}
>
<CardContent className="p-0 relative rounded-lg">
@@ -119,9 +126,13 @@ export function DashboardMap({
variant="default"
size="icon"
onClick={() => setIsMaximized(!isMaximized)}
title={isMaximized ? "Exit Fullscreen" : "Maximize Map"}
title={isMaximized ? 'Exit Fullscreen' : 'Maximize Map'}
>
{isMaximized ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
{isMaximized ? (
<Minimize2 className="h-5 w-5" />
) : (
<Maximize2 className="h-5 w-5" />
)}
</Button>
</div>
</div>
@@ -133,9 +144,12 @@ export function DashboardMap({
{Object.values(DETECTION_TYPES).map((type) => {
const typeId = type.id.toLowerCase();
const count = detections.filter(
(d) => (d.type || d.class || '').toLowerCase() === typeId && d.latitude && d.longitude
(d) =>
(d.type || d.class || '').toLowerCase() === typeId &&
d.latitude &&
d.longitude,
).length;
if (
count === 0 &&
(type.id.includes('culvert') || type.id === 'drain_issue')
@@ -143,21 +157,20 @@ export function DashboardMap({
return null;
if (type.id === 'good_sign_board') return null;
const isSelected = selectedTypes.length === 0 || selectedTypes.includes(typeId);
const isSelected =
selectedTypes.length === 0 || selectedTypes.includes(typeId);
return (
<button
key={type.id}
onClick={() => toggleType(typeId)}
className={`flex items-center gap-2 px-2 py-1.5 text-zinc-100 rounded-full transition-all text-xs ${
isSelected
? 'bg-zinc-800/80 '
: 'bg-transparent '
isSelected ? 'bg-zinc-800/80 ' : 'bg-transparent '
}`}
>
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: type.color }}
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: type.color }}
/>
<span>
{type.label} ({count})
@@ -169,7 +182,9 @@ export function DashboardMap({
</div>
</div>
<div className={`${isMaximized ? 'h-screen' : 'h-[600px]'} w-full relative transition-all`}>
<div
className={`${isMaximized ? 'h-screen' : 'h-[600px]'} w-full relative transition-all`}
>
{isLoading ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
@@ -181,7 +196,9 @@ export function DashboardMap({
) : validDetections.length === 0 ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<div className="text-center">
<p className="text-muted-foreground">No detections with GPS coordinates found</p>
<p className="text-muted-foreground">
No detections with GPS coordinates found
</p>
<p className="text-sm text-muted-foreground mt-1 opacity-70">
Process some videos to see detections on the map
</p>
@@ -192,7 +209,6 @@ export function DashboardMap({
)}
{/* Bottom Left Label Overlay */}
</div>
</CardContent>
</Card>

View File

@@ -45,9 +45,21 @@ export function DashboardSkeleton() {
</CardHeader>
<CardContent className="h-[300px] flex items-end justify-between gap-2 px-6 pb-8 pt-4">
{[...Array(8)].map((_, i) => {
const heights = ['60%', '40%', '75%', '50%', '65%', '35%', '80%', '45%'];
const heights = [
'60%',
'40%',
'75%',
'50%',
'65%',
'35%',
'80%',
'45%',
];
return (
<div key={i} className="flex flex-col gap-1 items-center flex-1">
<div
key={i}
className="flex flex-col gap-1 items-center flex-1"
>
<Skeleton
className="w-full rounded-t-sm"
style={{ height: heights[i % heights.length] }}

View File

@@ -96,7 +96,7 @@ export function DetectionDonutChart({
defectedSignboard,
roadCrack,
damagedRoadMarking,
// goodSignboard,
// goodSignboard,
drainIssue,
defectiveCulvert,
],
@@ -124,15 +124,32 @@ export function DetectionDonutChart({
}
return (
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Pie data={chartData} dataKey="count" nameKey="type" innerRadius={60} strokeWidth={5}>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={chartData}
dataKey="count"
nameKey="type"
innerRadius={60}
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}

View File

@@ -38,7 +38,9 @@ export function StatsCard({
<>
<p className="text-3xl font-bold tracking-tight">{value}</p>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{subtitle}</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{subtitle}
</p>
)}
</>
)}

View File

@@ -36,7 +36,7 @@ export function TableFooter<TData>({
</span>
{onPageSizeChange ? (
<div className="flex items-center gap-2">
<span >Rows per page</span>
<span>Rows per page</span>
<Select
value={String(currentPageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}

View File

@@ -5,7 +5,11 @@ import type { MouseEvent, ReactNode } from 'react';
import { PermissionGuard } from '@/guards';
import type { PermissionInput } from '@/hooks/usePermissions';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface TableActionButtonProps {

View File

@@ -1,8 +1,16 @@
'use client';
import { flexRender } from '@tanstack/react-table';
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
import {
TableHead,
TableHeader as ShadTableHeader,
TableRow,
} from '@/components/ui/table';
import { Table } from '@tanstack/react-table';
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
import {
ArrowDownWideNarrow,
ArrowUpDown,
ArrowUpNarrowWide,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
@@ -23,7 +31,9 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
return (
<TableHead
key={header.id}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onClick={
canSort ? header.column.getToggleSortingHandler() : undefined
}
className={cn(
'h-10 bg-card px-2 text-foreground transition-colors',
canSort && 'cursor-pointer select-none hover:bg-muted/60',
@@ -33,13 +43,18 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
{header.isPlaceholder ? null : (
<div className="flex min-h-8 w-full items-center justify-between gap-2">
<span className="truncate">
{flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{canSort ? (
<SortIcon
className={cn(
'size-3.5 shrink-0 transition-colors',
sortDirection ? 'text-foreground' : 'text-muted-foreground/60',
sortDirection
? 'text-foreground'
: 'text-muted-foreground/60',
)}
/>
) : null}

View File

@@ -67,12 +67,15 @@ export function DataTable<TData, TValue>({
onSortingChange,
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({});
const [internalSorting, setInternalSorting] = React.useState<SortingState>([]);
const [internalSorting, setInternalSorting] = React.useState<SortingState>(
[],
);
const sorting = controlledSorting ?? internalSorting;
const isManualSorting = Boolean(onSortingChange);
const handleSortingChange = React.useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater;
const nextSorting =
typeof updater === 'function' ? updater(sorting) : updater;
if (onSortingChange) {
onSortingChange(nextSorting);
return;
@@ -95,7 +98,9 @@ export function DataTable<TData, TValue>({
<div className="flex justify-end gap-2">
{actions.map((action) => {
const label =
typeof action.label === 'function' ? action.label(item) : action.label;
typeof action.label === 'function'
? action.label(item)
: action.label;
return (
<TableActionButton
@@ -131,7 +136,9 @@ export function DataTable<TData, TValue>({
getPaginationRowModel: getPaginationRowModel(),
manualPagination: !!pagination,
manualSorting: isManualSorting,
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1,
pageCount: pagination?.totalItems
? Math.ceil(pagination.totalItems / pagination.limit)
: -1,
state: {
rowSelection,
sorting,
@@ -191,17 +198,26 @@ export function DataTable<TData, TValue>({
))
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-28 text-center">
<TableCell
colSpan={columns.length}
className="h-28 text-center"
>
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
<p className="text-sm font-medium">{emptyTitle}</p>
<p className="text-xs">{emptyDescription}</p>

View File

@@ -30,13 +30,16 @@ export function FormField({
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between gap-3">
<Label htmlFor={id} className={labelClassName}>
{label} {required ? <span className="text-destructive">*</span> : null}
{label}{' '}
{required ? <span className="text-destructive">*</span> : null}
</Label>
{labelEnd}
</div>
{children}
{error ? (
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>{error}</p>
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>
{error}
</p>
) : null}
</div>
);

View File

@@ -48,15 +48,19 @@ export function RoleCombobox({
effective_status: true,
sort_by: 'display_name',
sort_order: 'asc',
}),
}),
mapOption: mapRoleOption,
resolveSelected: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId ? roleService.getRoleById(numericRoleId) : Promise.resolve(null);
return numericRoleId
? roleService.getRoleById(numericRoleId)
: Promise.resolve(null);
},
resolveSelectedQueryKey: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId ? roleKeys.detail(numericRoleId) : [...roleKeys.details(), 'invalid', roleId];
return numericRoleId
? roleKeys.detail(numericRoleId)
: [...roleKeys.details(), 'invalid', roleId];
},
});

View File

@@ -1,11 +1,26 @@
'use client';
import { useEffect } from 'react';
import { MapContainer, TileLayer, Polyline, CircleMarker, Popup, useMap } from 'react-leaflet';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
MapContainer,
TileLayer,
Polyline,
CircleMarker,
Popup,
useMap,
} from 'react-leaflet';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { LatLngBounds, LatLng } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { getDetectionModeConfig, DETECTION_TYPES } from '@/constants/detectionModeConfig';
import {
getDetectionModeConfig,
DETECTION_TYPES,
} from '@/constants/detectionModeConfig';
import { DetectionType } from '@/types';
type Detection = {
@@ -47,7 +62,12 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
return null;
}
export default function MapModal({ open, onClose, detections, detectionType }: MapModalProps) {
export default function MapModal({
open,
onClose,
detections,
detectionType,
}: MapModalProps) {
const validDetections = detections.filter((d) => d.latitude && d.longitude);
if (validDetections.length === 0) {
@@ -73,7 +93,9 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
);
routeCoordinates.forEach((coord) => bounds.extend(new LatLng(coord[0], coord[1])));
routeCoordinates.forEach((coord) =>
bounds.extend(new LatLng(coord[0], coord[1])),
);
const center: [number, number] = [
(bounds.getNorth() + bounds.getSouth()) / 2,
(bounds.getEast() + bounds.getWest()) / 2,
@@ -85,20 +107,32 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-6xl h-[85vh] p-0 flex flex-col overflow-hidden border-none shadow-2xl">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-xl font-bold">{modeConfig.label} Map</DialogTitle>
<DialogTitle className="text-xl font-bold">
{modeConfig.label} Map
</DialogTitle>
<p className="text-xs text-muted-foreground font-medium">
{validDetections.length} points of interest identified with GPS data
</p>
</DialogHeader>
<div className="flex-1 w-full relative bg-muted/20">
<MapContainer center={center} zoom={13} className="h-full w-full" scrollWheelZoom={true}>
<MapContainer
center={center}
zoom={13}
className="h-full w-full"
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Polyline positions={routeCoordinates} color="#3b82f6" weight={4} opacity={0.6} />
<Polyline
positions={routeCoordinates}
color="#3b82f6"
weight={4}
opacity={0.6}
/>
{validDetections.map((detection, idx) => {
const type = (detection.type || '').toLowerCase();
@@ -121,30 +155,51 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
<Popup>
<div className="text-sm min-w-[200px] py-1">
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none capitalize">
{(detection.type || '').replace(/_/g, ' ')} <span className="text-muted-foreground font-medium text-sm ml-1">#{detection.id}</span>
{(detection.type || '').replace(/_/g, ' ')}{' '}
<span className="text-muted-foreground font-medium text-sm ml-1">
#{detection.id}
</span>
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Frame</span>
<span className="font-medium text-right font-mono">{detection.frame_number}</span>
<span className="text-muted-foreground text-xs">
Frame
</span>
<span className="font-medium text-right font-mono">
{detection.frame_number}
</span>
</div>
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Confidence</span>
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
<span className="text-muted-foreground text-xs">
Confidence
</span>
<span className="font-medium text-right">
{(detection.confidence * 100).toFixed(1)}%
</span>
</div>
</div>
<div className="pt-2 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">Location Details</div>
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
Location Details
</div>
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
<span className="font-medium tracking-tighter">{detection.latitude.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Latitude
</span>
<span className="font-medium tracking-tighter">
{detection.latitude.toFixed(6)}
</span>
</div>
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
<span className="font-medium tracking-tighter">{detection.longitude.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Longitude
</span>
<span className="font-medium tracking-tighter">
{detection.longitude.toFixed(6)}
</span>
</div>
</div>
</div>

View File

@@ -25,9 +25,15 @@ export function ModeToggle() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -1,6 +1,13 @@
'use client';
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -118,15 +125,15 @@ export function NavUser({
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut />
{isLoggingOut ? 'Logging out' : 'Log out'}
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut />
{isLoggingOut ? 'Logging out' : 'Log out'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -11,15 +11,20 @@ interface PageHeaderProps {
actions?: React.ReactNode;
}
export function PageHeader({ title, description, icon: Icon, children, actions }: PageHeaderProps) {
export function PageHeader({
title,
description,
icon: Icon,
children,
actions,
}: PageHeaderProps) {
return (
<Reveal direction="down" className="w-full">
<div className="flex items-center justify-between w-full">
<div className="flex flex-col">
<h1>{title}</h1>
<p className="text-muted-foreground opacity-80">{description}</p>
</div>
<div className="flex flex-col">
<h1>{title}</h1>
<p className="text-muted-foreground opacity-80">{description}</p>
</div>
{actions && <div className="flex items-center gap-4">{actions}</div>}
</div>
</Reveal>

View File

@@ -3,7 +3,9 @@
import { CheckboxTree } from '@/components/ui/tree';
import type { PermissionTreeItem, PermissionTreeNode } from '@/types';
export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] {
export function mapPermissionTree(
nodes: PermissionTreeNode[],
): PermissionTreeItem[] {
return nodes.map((node) => ({
id: node.id,
key: node.slug || node.name,
@@ -15,14 +17,19 @@ export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeIt
}));
}
export function collectDefaultPermissionIds(nodes: PermissionTreeItem[]): number[] {
export function collectDefaultPermissionIds(
nodes: PermissionTreeItem[],
): number[] {
return nodes.flatMap((node) => [
...(node.isGrantedByDefault ? [node.id] : []),
...collectDefaultPermissionIds(node.children),
]);
}
export function collectPermissionIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] {
export function collectPermissionIdsByKeys(
nodes: PermissionTreeItem[],
keys: string[],
): number[] {
const selectedKeys = new Set(keys);
return nodes.flatMap((node) => [
...(selectedKeys.has(node.key) ? [node.id] : []),

View File

@@ -1,7 +1,13 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
@@ -12,8 +18,17 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Loader2, Check, ArrowUp, ArrowDown } from 'lucide-react';
import { projectService, packageService, chainageService } from '@/services/api';
import { Project, Package as PackageType, Chainage, SessionContext } from '@/types';
import {
projectService,
packageService,
chainageService,
} from '@/services/api';
import {
Project,
Package as PackageType,
Chainage,
SessionContext,
} from '@/types';
import { cn } from '@/lib/utils';
type ProjectSelectionSectionProps = {
@@ -26,21 +41,27 @@ type ProjectSelectionSectionProps = {
let globalProjectsCache: Project[] | null = null;
let projectsPromise: Promise<Project[]> | null = null;
export function ProjectSelectionSection({
onSelectionComplete,
export function ProjectSelectionSection({
onSelectionComplete,
onSelectionChange,
asStep = false,
hideButton = false
hideButton = false,
}: ProjectSelectionSectionProps) {
// Data states
const [projects, setProjects] = useState<Project[]>(globalProjectsCache || []);
const [projects, setProjects] = useState<Project[]>(
globalProjectsCache || [],
);
const [packages, setPackages] = useState<PackageType[]>([]);
const [chainages, setChainages] = useState<Chainage[]>([]);
// Selection states
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(null);
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(null);
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(
null,
);
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(
null,
);
// Loading states
const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache);
@@ -53,7 +74,7 @@ export function ProjectSelectionSection({
// Load projects on mount
useEffect(() => {
let isMounted = true;
const loadProjects = async () => {
// Use cache if we already have it
if (globalProjectsCache) {
@@ -66,13 +87,16 @@ export function ProjectSelectionSection({
// If a fetch is already in flight, reuse that promise
if (!projectsPromise) {
projectsPromise = projectService.getProjects().then(data => {
globalProjectsCache = data.items;
return data.items;
}).catch(err => {
projectsPromise = null; // Reset on error to allow retry
throw err;
});
projectsPromise = projectService
.getProjects()
.then((data) => {
globalProjectsCache = data.items;
return data.items;
})
.catch((err) => {
projectsPromise = null; // Reset on error to allow retry
throw err;
});
}
try {
@@ -93,7 +117,9 @@ export function ProjectSelectionSection({
};
loadProjects();
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, []);
// Load packages when project changes
@@ -112,7 +138,9 @@ export function ProjectSelectionSection({
setSelectedPackage(null);
setSelectedChainage(null);
setChainages([]);
const data = await packageService.getPackagesByProject(selectedProject.id);
const data = await packageService.getPackagesByProject(
selectedProject.id,
);
if (isMounted) {
setPackages(data.items);
}
@@ -128,7 +156,9 @@ export function ProjectSelectionSection({
}
};
loadPackages();
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, [selectedProject]);
// Load chainages when package changes
@@ -145,7 +175,9 @@ export function ProjectSelectionSection({
setLoadingChainages(true);
setError(null);
setSelectedChainage(null);
const data = await chainageService.getChainagesByPackage(selectedPackage.id);
const data = await chainageService.getChainagesByPackage(
selectedPackage.id,
);
if (isMounted) {
setChainages(data.items);
}
@@ -161,7 +193,9 @@ export function ProjectSelectionSection({
}
};
loadChainages();
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, [selectedPackage]);
const handleProjectChange = (projectId: string) => {
@@ -185,13 +219,14 @@ export function ProjectSelectionSection({
// Handle change reporting
useEffect(() => {
if (onSelectionChange) {
const currentIds = selectedProject && selectedPackage && selectedChainage
? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}`
: null;
const currentIds =
selectedProject && selectedPackage && selectedChainage
? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}`
: null;
if (currentIds !== lastReportedIdRef.current) {
lastReportedIdRef.current = currentIds;
if (selectedProject && selectedPackage && selectedChainage) {
onSelectionChange({
projectId: selectedProject.id,
@@ -210,7 +245,12 @@ export function ProjectSelectionSection({
}, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]);
const handleProceed = () => {
if (selectedProject && selectedPackage && selectedChainage && onSelectionComplete) {
if (
selectedProject &&
selectedPackage &&
selectedChainage &&
onSelectionComplete
) {
onSelectionComplete({
projectId: selectedProject.id,
projectName: selectedProject.name,
@@ -228,8 +268,18 @@ export function ProjectSelectionSection({
// Step status helpers
const getStepStatus = (step: number) => {
if (step === 1) return selectedProject ? 'completed' : 'active';
if (step === 2) return selectedPackage ? 'completed' : selectedProject ? 'active' : 'pending';
if (step === 3) return selectedChainage ? 'completed' : selectedPackage ? 'active' : 'pending';
if (step === 2)
return selectedPackage
? 'completed'
: selectedProject
? 'active'
: 'pending';
if (step === 3)
return selectedChainage
? 'completed'
: selectedPackage
? 'active'
: 'pending';
return 'pending';
};
@@ -291,7 +341,9 @@ export function ProjectSelectionSection({
</div>
) : (
<SelectValue
placeholder={selectedProject ? 'Select package' : 'Select project first'}
placeholder={
selectedProject ? 'Select package' : 'Select project first'
}
/>
)}
</SelectTrigger>
@@ -323,7 +375,9 @@ export function ProjectSelectionSection({
</div>
) : (
<SelectValue
placeholder={selectedPackage ? 'Select segment' : 'Select package first'}
placeholder={
selectedPackage ? 'Select segment' : 'Select package first'
}
/>
)}
</SelectTrigger>
@@ -331,9 +385,13 @@ export function ProjectSelectionSection({
{chainages.map((chn) => (
<SelectItem key={chn.id} value={chn.id}>
<div className="flex flex-col gap-0.5 py-0.5">
<span className="font-semibold text-sm">{chn.segment_name}</span>
<span className="font-semibold text-sm">
{chn.segment_name}
</span>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground font-medium">
<span>{chn.chainage_start_km}-{chn.chainage_end_km} km</span>
<span>
{chn.chainage_start_km}-{chn.chainage_end_km} km
</span>
<span className="h-2.5 w-px bg-border" />
<span className="flex items-center gap-1 uppercase">
{chn.direction === 'UP' ? (
@@ -399,9 +457,12 @@ export function ProjectSelectionSection({
<CardHeader className="pb-6">
<div className="flex flex-col gap-6">
<div>
<CardTitle className="text-2xl font-bold">Select Project Segment</CardTitle>
<CardTitle className="text-2xl font-bold">
Select Project Segment
</CardTitle>
<CardDescription className="mt-2 text-base">
Select Project, Package & Segment to begin intelligent road analysis.
Select Project, Package & Segment to begin intelligent road
analysis.
</CardDescription>
</div>
@@ -423,12 +484,18 @@ export function ProjectSelectionSection({
: 'bg-muted text-muted-foreground',
)}
>
{status === 'completed' ? <Check className="h-5 w-5" /> : step}
{status === 'completed' ? (
<Check className="h-5 w-5" />
) : (
step
)}
</div>
<span
className={cn(
'text-xs mt-2 font-medium',
status === 'pending' ? 'text-muted-foreground/60' : 'text-foreground',
status === 'pending'
? 'text-muted-foreground/60'
: 'text-foreground',
)}
>
{labels[index]}
@@ -438,7 +505,9 @@ export function ProjectSelectionSection({
<div
className={cn(
'w-16 h-0.5 mx-2 mb-6 rounded-full',
getStepStatus(step + 1) !== 'pending' ? 'bg-primary' : 'bg-border',
getStepStatus(step + 1) !== 'pending'
? 'bg-primary'
: 'bg-border',
)}
/>
)}
@@ -449,9 +518,7 @@ export function ProjectSelectionSection({
</div>
</CardHeader>
<CardContent className="pt-0">
{content}
</CardContent>
<CardContent className="pt-0">{content}</CardContent>
</Card>
);
}

View File

@@ -5,17 +5,26 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
@@ -32,7 +41,10 @@ function AvatarFallback({
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
);

View File

@@ -10,7 +10,8 @@ const badgeVariants = cva(
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90',
outline:
@@ -30,7 +31,8 @@ function Badge({
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span';
return (

View File

@@ -62,7 +62,11 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
@@ -76,7 +80,10 @@ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentP
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"

View File

@@ -10,11 +10,14 @@ const buttonVariants = cva(
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60',
destructive:
'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:border-ring dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {

View File

@@ -52,14 +52,23 @@ 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)}
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
@@ -72,4 +81,12 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -42,7 +42,9 @@ function ChartContainer({
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
@@ -59,14 +61,18 @@ function ChartContainer({
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
<RechartsPrimitive.ResponsiveContainer>
{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);
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
@@ -81,7 +87,9 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
@@ -135,7 +143,9 @@ function ChartTooltipContent({
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
@@ -144,7 +154,15 @@ function ChartTooltipContent({
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
@@ -287,29 +305,42 @@ function ChartLegendContent({
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {

View File

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

View File

@@ -31,7 +31,9 @@ function ComboboxTrigger({
className="pointer-events-none size-4 text-muted-foreground"
/>
);
const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(render)
const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(
render,
)
? React.cloneElement(render, {
children: (
<>
@@ -81,7 +83,10 @@ function ComboboxInput({
}) {
return (
<InputGroup className={cn('w-auto', className)}>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
@@ -155,7 +160,11 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
);
}
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
@@ -180,11 +189,18 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
@@ -198,7 +214,9 @@ function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Pro
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
@@ -214,7 +232,10 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
);
}
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
@@ -227,7 +248,8 @@ function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
@@ -271,7 +293,11 @@ function ComboboxChip({
);
}
function ComboboxChipsInput({ className, children, ...props }: ComboboxPrimitive.Input.Props) {
function ComboboxChipsInput({
className,
children,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"

View File

@@ -7,19 +7,27 @@ import { Dialog as DialogPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
@@ -99,7 +107,10 @@ function DialogFooter({
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
>
{children}
@@ -112,8 +123,17 @@ function DialogFooter({
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn(className)} {...props} />;
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(className)}
{...props}
/>
);
}
function DialogDescription({

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 { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
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,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -42,13 +42,13 @@ function DropdownMenuContent({
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
'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,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -56,17 +56,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
@@ -75,11 +75,11 @@ function DropdownMenuItem({
data-variant={variant}
className={cn(
"relative flex cursor-default 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]:pointer-events-none 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
className,
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default 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]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
checked={checked}
{...props}
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default 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]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -148,19 +148,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-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -170,32 +170,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',
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,7 +204,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
data-inset={inset}
className={cn(
"flex cursor-default 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
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
<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
'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,
)}
{...props}
/>
)
);
}
export {
@@ -254,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};

View File

@@ -41,8 +41,10 @@ const inputGroupAddonVariants = cva(
{
variants: {
align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'inline-start':
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
'block-end':
@@ -77,19 +79,23 @@ function InputGroupAddon({
);
}
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
defaultVariants: {
size: 'xs',
},
});
);
function InputGroupButton({
className,
@@ -122,7 +128,10 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
@@ -135,7 +144,10 @@ function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>)
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"

View File

@@ -5,7 +5,10 @@ import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"

View File

@@ -1,5 +1,9 @@
import * as React from 'react';
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
@@ -16,7 +20,10 @@ function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
);
}
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
@@ -35,7 +42,12 @@ type PaginationLinkProps = {
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
@@ -53,7 +65,10 @@ function PaginationLink({ className, isActive, size = 'icon', ...props }: Pagina
);
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
@@ -67,7 +82,10 @@ function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof
);
}
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
@@ -81,7 +99,10 @@ function PaginationNext({ className, ...props }: React.ComponentProps<typeof Pag
);
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden

View File

@@ -5,11 +5,15 @@ import { Popover as PopoverPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
@@ -35,7 +39,9 @@ function PopoverContent({
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
@@ -50,10 +56,19 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />;
return (
<div
data-slot="popover-title"
className={cn('font-medium', className)}
{...props}
/>
);
}
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
function PopoverDescription({
className,
...props
}: React.ComponentProps<'p'>) {
return (
<p
data-slot="popover-description"

View File

@@ -13,7 +13,10 @@ function Progress({
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator

View File

@@ -39,8 +39,10 @@ function ScrollBar({
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}

View File

@@ -6,15 +6,21 @@ import { Select as SelectPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
@@ -81,7 +87,10 @@ function SelectContent({
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
@@ -138,7 +147,10 @@ function SelectScrollUpButton({
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
@@ -153,7 +165,10 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />

View File

@@ -10,15 +10,21 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
@@ -99,7 +105,10 @@ function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"

View File

@@ -18,7 +18,12 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
@@ -91,7 +96,10 @@ function SidebarProvider({
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
@@ -245,7 +253,11 @@ function Sidebar({
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
@@ -306,7 +318,10 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
@@ -339,7 +354,10 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
@@ -419,7 +437,10 @@ function SidebarGroupAction({
);
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
@@ -556,7 +577,10 @@ function SidebarMenuAction({
);
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
@@ -592,7 +616,12 @@ function SidebarMenuSkeleton({
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
@@ -621,7 +650,10 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"

View File

@@ -39,7 +39,11 @@ export function Stepper({ steps, activeStep, className }: StepperProps) {
: 'bg-background border-muted text-muted-foreground',
)}
>
{isCompleted ? <Check className="h-5 w-5" /> : <span>{index + 1}</span>}
{isCompleted ? (
<Check className="h-5 w-5" />
) : (
<span>{index + 1}</span>
)}
</div>
<div className="mt-4 text-center px-2">

View File

@@ -24,7 +24,13 @@ function Table({
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
@@ -41,7 +47,10 @@ function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
);
@@ -86,7 +95,10 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
);
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
@@ -96,4 +108,13 @@ function TableCaption({ className, ...props }: React.ComponentProps<'caption'>)
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import * as React from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
@@ -15,19 +15,19 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
)
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -42,8 +42,8 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 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",
className
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 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',
className,
)}
{...props}
>
@@ -51,7 +51,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -11,8 +11,13 @@ export interface TreeItem {
children: TreeItem[];
}
export function collectTreeItemIds<TItem extends TreeItem>(item: TItem): number[] {
return [item.id, ...item.children.flatMap((child) => collectTreeItemIds(child as TItem))];
export function collectTreeItemIds<TItem extends TreeItem>(
item: TItem,
): number[] {
return [
item.id,
...item.children.flatMap((child) => collectTreeItemIds(child as TItem)),
];
}
function TreeCheckbox({
@@ -71,7 +76,9 @@ function CheckboxTreeNode<TItem extends TreeItem>({
<button
type="button"
onClick={() => setIsExpanded((current) => !current)}
aria-label={isExpanded ? `Collapse ${item.label}` : `Expand ${item.label}`}
aria-label={
isExpanded ? `Collapse ${item.label}` : `Expand ${item.label}`
}
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<ChevronDown
@@ -87,12 +94,16 @@ function CheckboxTreeNode<TItem extends TreeItem>({
onChange={(isChecked) => onToggle(item, isChecked)}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-sm font-medium leading-none">{item.label}</span>
<span className="truncate text-sm font-medium leading-none">
{item.label}
</span>
{renderMeta?.(item)}
</div>
</div>
{showDescription && item.description ? (
<p className="mt-1.5 pl-11 text-xs text-muted-foreground">{item.description}</p>
<p className="mt-1.5 pl-11 text-xs text-muted-foreground">
{item.description}
</p>
) : null}
</div>
{hasChildren && isExpanded ? (

View File

@@ -1,21 +1,44 @@
'use client';
import { useMemo, useState, useCallback, useRef, useReducer, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
useMemo,
useState,
useCallback,
useRef,
useReducer,
useEffect,
} from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Film } from 'lucide-react';
import { DetectionData, DetectionType, DetectionCounts, DetectionLogEntry } from '@/types';
import {
DetectionData,
DetectionType,
DetectionCounts,
DetectionLogEntry,
} from '@/types';
import { useGpsMap } from '@/hooks/use-gps-map';
import { useFrameDetectionMap } from '@/hooks/use-frame-detection-map';
import { useCumulativeCounts } from '@/hooks/use-cumulative-counts';
import { useVideoDetectionLoop } from '@/hooks/use-video-detection-loop';
import VideoCanvasPlayer, { VideoCanvasPlayerRef } from './video/video-canvas-player';
import VideoCanvasPlayer, {
VideoCanvasPlayerRef,
} from './video/video-canvas-player';
import DetectionStatsBar from './video/detection-stats-bar';
import DetectionLogs from './video/detection-logs';
import SummarySection from './video/summary-section';
import DetailedSummarySection from './video/detailed-summary-section';
import { getDetectionModeConfig, getEnabledDetectionTypes } from '@/constants/detectionModeConfig';
import {
getDetectionModeConfig,
getEnabledDetectionTypes,
} from '@/constants/detectionModeConfig';
type VideoPlayerSectionProps = {
data: DetectionData;
@@ -27,13 +50,20 @@ type VideoPlayerSectionProps = {
const MAX_LOGS = 50;
type LogAction = { type: 'ADD_LOG'; payload: DetectionLogEntry } | { type: 'CLEAR' };
type LogAction =
| { type: 'ADD_LOG'; payload: DetectionLogEntry }
| { type: 'CLEAR' };
function logsReducer(state: DetectionLogEntry[], action: LogAction): DetectionLogEntry[] {
function logsReducer(
state: DetectionLogEntry[],
action: LogAction,
): DetectionLogEntry[] {
switch (action.type) {
case 'ADD_LOG':
// Move to top if already exists, or just add to top
const filtered = state.filter((log) => log.frame !== action.payload.frame);
const filtered = state.filter(
(log) => log.frame !== action.payload.frame,
);
return [action.payload, ...filtered].slice(0, MAX_LOGS);
case 'CLEAR':
return [];
@@ -81,7 +111,8 @@ export default function VideoPlayerSection({
return counts as DetectionCounts;
}, [enabledTypes]);
const [currentFrameCounts, setCurrentFrameCounts] = useState<DetectionCounts>(initialCounts);
const [currentFrameCounts, setCurrentFrameCounts] =
useState<DetectionCounts>(initialCounts);
// Logs Reducer
const [logs, dispatchLogs] = useReducer(logsReducer, []);
@@ -95,10 +126,10 @@ export default function VideoPlayerSection({
// Synthesize frames from lists if not present (specifically for gemini_video)
// Other models like YOLO return data.frames directly
const framesMap = new Map<number, any>();
// Sort all detections by their first_detected_frame
const allDetections: any[] = [];
enabledTypes.forEach(type => {
enabledTypes.forEach((type) => {
const list = (data as any)[type.listKey];
if (list && Array.isArray(list)) {
list.forEach((item: any) => {
@@ -107,50 +138,57 @@ export default function VideoPlayerSection({
}
});
allDetections.sort((a, b) => (a.first_detected_frame || 0) - (b.first_detected_frame || 0));
allDetections.sort(
(a, b) => (a.first_detected_frame || 0) - (b.first_detected_frame || 0),
);
// Current counts for sticky stats
const currentCounts: Record<string, number> = {};
enabledTypes.forEach(t => { currentCounts[t.frameCountKey] = 0; });
enabledTypes.forEach((t) => {
currentCounts[t.frameCountKey] = 0;
});
allDetections.forEach(det => {
allDetections.forEach((det) => {
const frameId = det.first_detected_frame || det.frame_number || 0;
// Spread detection across multiple frames so it stays visible (persistence)
// Gemini detections are sparse, so showing them for ~1 second (30 frames) helps
const persistenceFrames = 30;
const persistenceFrames = 30;
for (let i = 0; i < persistenceFrames; i++) {
const targetFrame = frameId + i;
if (!framesMap.has(targetFrame)) {
framesMap.set(targetFrame, { frame_id: targetFrame, detections: [] });
}
const frameData = framesMap.get(targetFrame);
// Update cumulative counts only on the first detected frame
if (i === 0) {
const typeConfig = enabledTypes.find(t => t.id === det._detType);
const typeConfig = enabledTypes.find((t) => t.id === det._detType);
if (typeConfig) {
currentCounts[typeConfig.frameCountKey] = (currentCounts[typeConfig.frameCountKey] || 0) + 1;
currentCounts[typeConfig.frameCountKey] =
(currentCounts[typeConfig.frameCountKey] || 0) + 1;
}
}
const countCopy = { ...currentCounts };
frameData.detections.push({
...det,
type: det.type || det._detType,
detection_id: det.detection_id || det.id,
count: countCopy
count: countCopy,
});
}
});
return {
...data,
frames: Array.from(framesMap.values()).sort((a, b) => (a.frame_id || 0) - (b.frame_id || 0))
frames: Array.from(framesMap.values()).sort(
(a, b) => (a.frame_id || 0) - (b.frame_id || 0),
),
};
}, [data, enabledTypes]);
@@ -160,7 +198,9 @@ export default function VideoPlayerSection({
normalizedData,
detectionType,
);
const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts(normalizedData.frames || []);
const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts(
normalizedData.frames || [],
);
// Memoized video URL
const videoUrl = useMemo(() => {
@@ -193,7 +233,8 @@ export default function VideoPlayerSection({
// Logging logic
if (dets && dets.length > 0) {
const firstDetId = dets[0].pothole_id ?? dets[0].signboard_id ?? dets[0].detection_id;
const firstDetId =
dets[0].pothole_id ?? dets[0].signboard_id ?? dets[0].detection_id;
const coords = gpsMap.get(firstDetId);
if (coords) {
@@ -211,14 +252,24 @@ export default function VideoPlayerSection({
type: d.type || d._detType,
bbox: d.bbox,
confidence: d.confidence,
latitude: gpsMap.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lat,
longitude: gpsMap.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lng,
latitude: gpsMap.get(
d.pothole_id ?? d.signboard_id ?? d.detection_id,
)?.lat,
longitude: gpsMap.get(
d.pothole_id ?? d.signboard_id ?? d.detection_id,
)?.lng,
})),
},
});
}
},
[data.video_info.fps, getNearestDetections, getStickyCounts, gpsMap, sortedDetectionIndices],
[
data.video_info.fps,
getNearestDetections,
getStickyCounts,
gpsMap,
sortedDetectionIndices,
],
);
// Playback loop hook
@@ -248,9 +299,14 @@ export default function VideoPlayerSection({
const currentFrame = Math.round(video.currentTime * fps);
// Snap to the next available frame index that has detections
const nextDetectionFrame = sortedDetectionIndices.find((f) => f >= currentFrame);
const nextDetectionFrame = sortedDetectionIndices.find(
(f) => f >= currentFrame,
);
if (nextDetectionFrame !== undefined && nextDetectionFrame !== currentFrame) {
if (
nextDetectionFrame !== undefined &&
nextDetectionFrame !== currentFrame
) {
video.currentTime = nextDetectionFrame / fps;
return;
}
@@ -276,7 +332,9 @@ export default function VideoPlayerSection({
<Film className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg font-bold">Detection Playback</CardTitle>
<CardTitle className="text-lg font-bold">
Detection Playback
</CardTitle>
<CardDescription className="text-xs">
Real-time object detection analysis
</CardDescription>
@@ -297,7 +355,10 @@ export default function VideoPlayerSection({
onLoadedData={handleLoadedData}
onEnded={handleVideoEnded}
onSeeked={handleVideoSeeked}
currentDetections={getNearestDetections(currentFrame, sortedDetectionIndices) || []}
currentDetections={
getNearestDetections(currentFrame, sortedDetectionIndices) ||
[]
}
/>
<DetectionStatsBar
@@ -316,7 +377,11 @@ export default function VideoPlayerSection({
{showSummary && (
<div className="space-y-6 pt-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<SummarySection data={data} show={showSummary} detectionType={detectionType} />
<SummarySection
data={data}
show={showSummary}
detectionType={detectionType}
/>
<DetailedSummarySection
projectId={projectId || ''}
videoId={videoId}

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