chore: update eslint and prettier configuration
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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='© <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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user