diff --git a/src/app/(modules)/upload/components/AnalysisSettingsSection.tsx b/src/app/(modules)/upload/components/AnalysisSettingsSection.tsx new file mode 100644 index 0000000..f5840dc --- /dev/null +++ b/src/app/(modules)/upload/components/AnalysisSettingsSection.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { FormField, SelectPopover } from '@/components/form'; + +const options = [ + { value: 'yolo', label: 'Road Defect Detection' }, + { value: 'yoloe', label: 'YOLOE Open-Vocabulary Detection' }, + { value: 'yoloe_trained_vl', label: 'YOLOE With Vision Language Model' }, + { value: 'culvert_detection', label: 'Culvert Detection' }, + { value: 'combined', label: 'Road Defect Detection with vl' }, +] as const; + +interface AnalysisSettingsSectionProps { + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; +} + +export function AnalysisSettingsSection({ + value, + onValueChange, + disabled = false, +}: AnalysisSettingsSectionProps) { + return ( + + + + Analysis Settings + + + Select the AI model workflow for this job. + + + + + + + + + + ); +} diff --git a/src/app/(modules)/upload/components/InputDataSection.tsx b/src/app/(modules)/upload/components/InputDataSection.tsx new file mode 100644 index 0000000..682d729 --- /dev/null +++ b/src/app/(modules)/upload/components/InputDataSection.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { UploadFileDropzone } from './UploadFileDropzone'; + +interface InputDataSectionProps { + videoFile: File | null; + gpsFile: File | null; + onVideoFileChange: (file: File | null) => void; + onGpsFileChange: (file: File | null) => void; + disabled?: boolean; +} + +export function InputDataSection({ + videoFile, + gpsFile, + onVideoFileChange, + onGpsFileChange, + disabled = false, +}: InputDataSectionProps) { + return ( + + + Input Data + + Attach the road video and GPS telemetry for processing. + + + + + + + + + ); +} + diff --git a/src/app/(modules)/upload/components/LocationSection.tsx b/src/app/(modules)/upload/components/LocationSection.tsx new file mode 100644 index 0000000..f46b252 --- /dev/null +++ b/src/app/(modules)/upload/components/LocationSection.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { FormField } from '@/components/form'; +import { PackageSelect } from '@/components/lookups/PackageSelect'; +import { ProjectSelect } from '@/components/lookups/ProjectSelect'; +import { SegmentSelect } from '@/components/lookups/SegmentSelect'; +import { packageService, projectService, chainageService } from '@/services/api'; +import type { Chainage, Package as PackageType, Project, SessionContext } from '@/types'; + +interface LocationSectionProps { + value: SessionContext | null; + onChange: (session: SessionContext | null) => void; + disabled?: boolean; +} + +export function LocationSection({ + value, + onChange, + disabled = false, +}: LocationSectionProps) { + const [projectId, setProjectId] = useState(value?.projectId ?? ''); + const [packageId, setPackageId] = useState(value?.packageId ?? ''); + const [segmentId, setSegmentId] = useState(value?.chainageId ?? ''); + const [project, setProject] = useState(null); + const [selectedPackage, setSelectedPackage] = useState( + null, + ); + const [segment, setSegment] = useState(null); + + const latestProjectIdRef = useRef(''); + const latestPackageIdRef = useRef(''); + const latestSegmentIdRef = useRef(''); + + useEffect(() => { + if (project && selectedPackage && segment) { + onChange({ + projectId: project.id, + projectName: project.name, + packageId: selectedPackage.id, + packageName: selectedPackage.name, + chainageId: segment.id, + chainageName: segment.segment_name, + chainageDirection: segment.direction, + }); + return; + } + + onChange(null); + }, [onChange, project, selectedPackage, segment]); + + const handleProjectChange = useCallback((nextProjectId: string) => { + latestProjectIdRef.current = nextProjectId; + latestPackageIdRef.current = ''; + latestSegmentIdRef.current = ''; + setProjectId(nextProjectId); + setPackageId(''); + setSegmentId(''); + setProject(null); + setSelectedPackage(null); + setSegment(null); + + if (!nextProjectId) return; + + void projectService.getProjectById(nextProjectId).then((nextProject) => { + if (latestProjectIdRef.current === nextProjectId) { + setProject(nextProject); + } + }); + }, []); + + const handlePackageChange = useCallback((nextPackageId: string) => { + latestPackageIdRef.current = nextPackageId; + latestSegmentIdRef.current = ''; + setPackageId(nextPackageId); + setSegmentId(''); + setSelectedPackage(null); + setSegment(null); + + if (!nextPackageId) return; + + void packageService.getPackageById(nextPackageId).then((nextPackage) => { + if (latestPackageIdRef.current === nextPackageId) { + setSelectedPackage(nextPackage); + } + }); + }, []); + + const handleSegmentChange = useCallback((nextSegmentId: string) => { + latestSegmentIdRef.current = nextSegmentId; + setSegmentId(nextSegmentId); + setSegment(null); + + if (!nextSegmentId) return; + + void chainageService.getChainageById(nextSegmentId).then((nextSegment) => { + if (latestSegmentIdRef.current === nextSegmentId) { + setSegment(nextSegment); + } + }); + }, []); + + return ( + + + Location + + Select the project hierarchy for this analysis job. + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/(modules)/upload/components/UploadFileDropzone.tsx b/src/app/(modules)/upload/components/UploadFileDropzone.tsx new file mode 100644 index 0000000..c96deaa --- /dev/null +++ b/src/app/(modules)/upload/components/UploadFileDropzone.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { FileCheck2, UploadCloud } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +interface UploadFileDropzoneProps { + title: string; + description: string; + accept: string; + file: File | null; + onFileChange: (file: File | null) => void; + disabled?: boolean; +} + +export function UploadFileDropzone({ + title, + description, + accept, + file, + onFileChange, + disabled = false, +}: UploadFileDropzoneProps) { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleFiles = (files: FileList | null) => { + if (disabled) return; + onFileChange(files?.[0] ?? null); + }; + + return ( + { + if (!disabled) inputRef.current?.click(); + }} + onKeyDown={(event) => { + if (!disabled && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + inputRef.current?.click(); + } + }} + onDragOver={(event) => { + event.preventDefault(); + if (!disabled) setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + handleFiles(event.dataTransfer.files); + }} + className={cn( + 'group flex min-h-32 cursor-pointer flex-col justify-between rounded-md border border-dashed bg-card/60 p-4 transition-colors', + 'hover:border-primary/60 hover:bg-secondary/40', + isDragging && 'border-primary bg-secondary/60', + disabled && 'pointer-events-none cursor-not-allowed opacity-60', + )} + > + handleFiles(event.target.files)} + disabled={disabled} + /> + + + + + + + {title} + {description} + + + + {file ? ( + + + {file.name} + + ) : null} + + ); +} + diff --git a/src/app/(modules)/upload/page.tsx b/src/app/(modules)/upload/page.tsx index e4a802d..35d9e9e 100644 --- a/src/app/(modules)/upload/page.tsx +++ b/src/app/(modules)/upload/page.tsx @@ -1,256 +1,134 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { FormField } from '@/components/form'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Loader2, TrendingUp } from 'lucide-react'; +import { Loader2, Play, TrendingUp } from 'lucide-react'; + import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; import { videoService } from '@/services/api'; -import { SessionContext } from '@/types'; -import { ProjectSelectionSection } from '@/components/project-selection-section'; -import { Reveal } from '@/components/ui/reveal'; -import { cn } from '@/lib/utils'; +import type { SessionContext } from '@/types'; import { ROUTES } from '@/utils/routes'; -const DETECTION_METHODS = [ - { value: 'yolo', label: 'Road Defect Detection' }, - // { value: 'yolo_vl', label: 'YOLO with Vision-Language Model' }, - // { value: 'sam3', label: 'OpenAI SAM 3 Segmentation Model' }, - { value: 'yoloe', label: 'YOLOE Open-Vocabulary Detection' }, - { value: 'yoloe_trained_vl', label: 'YOLOE With Vision Language Model' }, - { value: 'culvert_detection', label: 'Culvert Detection' }, - { value: 'combined', label: 'Road Defect Detection with vl' }, - // { value: 'gemini_video', label: 'Gemini AI Analysis' }, -] as const; +import { AnalysisSettingsSection } from './components/AnalysisSettingsSection'; +import { InputDataSection } from './components/InputDataSection'; +import { LocationSection } from './components/LocationSection'; export default function UploadPage() { const router = useRouter(); const [session, setSession] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - // Form states - const [file, setFile] = useState(null); - const [jsonFile, setJsonFile] = useState(null); - const [selectMethod, setSelectMethod] = useState('yolo'); - - // Upload states - const [uploading, setUploading] = useState(false); + const [videoFile, setVideoFile] = useState(null); + const [gpsFile, setGpsFile] = useState(null); + const [analysisMethod, setAnalysisMethod] = useState('yolo'); + const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - // Load session on mount - useEffect(() => { - // 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); - }, - [], + const isLocationComplete = Boolean( + session?.projectId && session.packageId && session.chainageId, + ); + const canSubmit = Boolean( + isLocationComplete && videoFile && gpsFile && analysisMethod, ); - const handleUpload = async () => { - if (!file) { - setError('Please select a video file'); - return; - } - - if (!jsonFile) { - setError('Please select a GPS JSON file'); - return; - } - - if (!session) { - setError('Please complete the project selection first'); + const handleSubmit = async () => { + if (!session?.chainageId || !videoFile || !gpsFile) { + setError('Complete location and input data before starting analysis.'); return; } const formData = new FormData(); - formData.append('file', file); - formData.append('detection_mode', selectMethod); - formData.append('chainage_id', session.chainageId as string); - formData.append('json_file', jsonFile); + formData.append('file', videoFile); + formData.append('detection_mode', analysisMethod); + formData.append('chainage_id', session.chainageId); + formData.append('json_file', gpsFile); - setUploading(true); + setIsSubmitting(true); setError(null); try { await videoService.uploadVideo(formData); router.push(ROUTES.TICKET); } catch (err) { - let errorMessage = 'Upload failed'; + setIsSubmitting(false); if (err instanceof TypeError && err.message === 'Failed to fetch') { - errorMessage = - 'Cannot connect to server. Please check if backend is running.'; - } else if (err instanceof Error) { - errorMessage = err.message; + setError('Cannot connect to server. Please check if backend is running.'); + return; } - setError(errorMessage); - setUploading(false); + setError(err instanceof Error ? err.message : 'Upload failed'); } }; - if (isLoading) { - return ( - - - - ); - } - - const isSessionComplete = !!( - session?.projectId && session.packageId && session.chainageId - ); - const isFormValid = isSessionComplete && file && jsonFile; - return ( - - - {/* Header */} - - + + + + + - - - {/* Module 1: Project Selection */} - - - - - 1. Project Details - - - Select the target project, package, and segment. - - - - - - - + - {/* Module 2: Upload Data */} - - { + setVideoFile(file); + setError(null); + }} + onGpsFileChange={(file) => { + setGpsFile(file); + setError(null); + }} + disabled={isSubmitting} + /> + + + + + + {error ? ( + + {error} + + ) : null} + + + - - - 2. Upload Video Data - - - Provide the video file and GPS telemetry for processing. - - - - - - { - setFile(e.target.files?.[0] || null); - setError(null); - }} - disabled={uploading || !isSessionComplete} - /> - - - - - { - setJsonFile(e.target.files?.[0] || null); - setError(null); - }} - disabled={uploading || !isSessionComplete} - required - /> - - - - - - - - - {DETECTION_METHODS.map((method) => ( - - {method.label} - - ))} - - - - - - - {/* Error Display */} - {error && ( - - {error} - - )} - - - - {uploading ? ( - - - Uploading... - - ) : ( - 'Upload and Process' - )} - - - - - - - + {isSubmitting ? ( + <> + + Starting analysis... + > + ) : ( + <> + + Start Analysis + > + )} + + + + ); } diff --git a/src/components/form/SelectPopover.tsx b/src/components/form/SelectPopover.tsx new file mode 100644 index 0000000..a97eb8d --- /dev/null +++ b/src/components/form/SelectPopover.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, ChevronDown } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +export interface SelectPopoverOption { + value: string; + label: string; + disabled?: boolean; +} + +interface SelectPopoverProps { + options: readonly SelectPopoverOption[]; + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + disabled?: boolean; + align?: 'start' | 'center' | 'end'; +} + +export function SelectPopover({ + options, + value, + onValueChange, + placeholder = 'Select option', + searchPlaceholder = 'Search', + emptyMessage = 'No options found.', + disabled = false, + align = 'start', +}: SelectPopoverProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const selectedOption = options.find((option) => option.value === value); + const filteredOptions = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return options; + return options.filter((option) => + option.label.toLowerCase().includes(term), + ); + }, [options, search]); + + return ( + + + + + {selectedOption?.label ?? placeholder} + + + + + + + setSearch(event.target.value)} + placeholder={searchPlaceholder} + /> + + + + {filteredOptions.length ? ( + filteredOptions.map((option) => { + const selected = option.value === value; + + return ( + { + onValueChange(option.value); + setOpen(false); + }} + className={cn( + 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted', + option.disabled && 'cursor-not-allowed opacity-60', + )} + > + + {selected ? : null} + + {option.label} + + ); + }) + ) : ( + + {emptyMessage} + + )} + + + + ); +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index c986d42..4d58867 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,4 +1,6 @@ export { FormField } from './FormField'; export { MultiSelectPopover } from './MultiSelectPopover'; export type { MultiSelectOption } from './MultiSelectPopover'; +export { SelectPopover } from './SelectPopover'; +export type { SelectPopoverOption } from './SelectPopover'; export { PasswordField } from './PasswordField'; diff --git a/src/components/project-selection-section.tsx b/src/components/project-selection-section.tsx deleted file mode 100644 index 2481fbc..0000000 --- a/src/components/project-selection-section.tsx +++ /dev/null @@ -1,373 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef, useCallback } from 'react'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Check, ArrowUp, ArrowDown } from 'lucide-react'; -import { ProjectSelect } from '@/components/lookups/ProjectSelect'; -import { PackageSelect } from '@/components/lookups/PackageSelect'; -import { SegmentSelect } from '@/components/lookups/SegmentSelect'; -import { - projectService, - packageService, - chainageService, -} from '@/services/api'; -import { - Project, - Package as PackageType, - Chainage, - SessionContext, -} from '@/types'; -import { cn } from '@/lib/utils'; - -type ProjectSelectionSectionProps = { - onSelectionComplete?: (session: SessionContext) => void; - onSelectionChange?: (session: SessionContext | null) => void; - asStep?: boolean; - hideButton?: boolean; -}; - -export function ProjectSelectionSection({ - onSelectionComplete, - onSelectionChange, - asStep = false, - hideButton = false, -}: ProjectSelectionSectionProps) { - const [selectedProjectId, setSelectedProjectId] = useState(''); - const [selectedPackageId, setSelectedPackageId] = useState(''); - const [selectedChainageId, setSelectedChainageId] = useState(''); - const [selectedProject, setSelectedProject] = useState(null); - const [selectedPackage, setSelectedPackage] = useState( - null, - ); - const [selectedChainage, setSelectedChainage] = useState( - null, - ); - const [error, setError] = useState(null); - - const lastReportedIdRef = useRef(null); - const latestProjectIdRef = useRef(''); - const latestPackageIdRef = useRef(''); - const latestChainageIdRef = useRef(''); - - const handleProjectChange = useCallback((projectId: string) => { - latestProjectIdRef.current = projectId; - latestPackageIdRef.current = ''; - latestChainageIdRef.current = ''; - - setSelectedProjectId(projectId); - setSelectedPackageId(''); - setSelectedChainageId(''); - setSelectedProject(null); - setSelectedPackage(null); - setSelectedChainage(null); - setError(null); - - if (!projectId) { - return; - } - - void projectService - .getProjectById(projectId) - .then((project) => { - if (latestProjectIdRef.current === projectId) { - setSelectedProject(project); - } - }) - .catch((err) => { - if (latestProjectIdRef.current === projectId) { - console.error('Failed to resolve project:', err); - setError('Failed to load the selected project.'); - } - }); - }, []); - - const handlePackageChange = useCallback((packageId: string) => { - latestPackageIdRef.current = packageId; - latestChainageIdRef.current = ''; - - setSelectedPackageId(packageId); - setSelectedChainageId(''); - setSelectedPackage(null); - setSelectedChainage(null); - setError(null); - - if (!packageId) { - return; - } - - void packageService - .getPackageById(packageId) - .then((pkg) => { - if (latestPackageIdRef.current === packageId) { - setSelectedPackage(pkg); - } - }) - .catch((err) => { - if (latestPackageIdRef.current === packageId) { - console.error('Failed to resolve package:', err); - setError('Failed to load the selected package.'); - } - }); - }, []); - - const handleChainageChange = useCallback((chainageId: string) => { - latestChainageIdRef.current = chainageId; - - setSelectedChainageId(chainageId); - setSelectedChainage(null); - setError(null); - - if (!chainageId) { - return; - } - - void chainageService - .getChainageById(chainageId) - .then((chainage) => { - if (latestChainageIdRef.current === chainageId) { - setSelectedChainage(chainage); - } - }) - .catch((err) => { - if (latestChainageIdRef.current === chainageId) { - console.error('Failed to resolve segment:', err); - setError('Failed to load the selected segment.'); - } - }); - }, []); - - // Handle change reporting - useEffect(() => { - if (onSelectionChange) { - 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, - projectName: selectedProject.name, - packageId: selectedPackage.id, - packageName: selectedPackage.name, - chainageId: selectedChainage.id, - chainageName: selectedChainage.segment_name, - chainageDirection: selectedChainage.direction, - }); - } else { - onSelectionChange(null); - } - } - } - }, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]); - - const handleProceed = () => { - if ( - selectedProject && - selectedPackage && - selectedChainage && - onSelectionComplete - ) { - onSelectionComplete({ - projectId: selectedProject.id, - projectName: selectedProject.name, - packageId: selectedPackage.id, - packageName: selectedPackage.name, - chainageId: selectedChainage.id, - chainageName: selectedChainage.segment_name, - chainageDirection: selectedChainage.direction, - }); - } - }; - - const isComplete = selectedProject && selectedPackage && selectedChainage; - - // Step status helpers - const getStepStatus = (step: number) => { - if (step === 1) return selectedProjectId ? 'completed' : 'active'; - if (step === 2) - return selectedPackageId - ? 'completed' - : selectedProjectId - ? 'active' - : 'pending'; - if (step === 3) - return selectedChainageId - ? 'completed' - : selectedPackageId - ? 'active' - : 'pending'; - return 'pending'; - }; - - const content = ( - <> - {/* Error Display */} - {error && ( - - {error} - - )} - - - - - Project - - - - - - - Package - - - - - - - Segment - - - - - - {/* Selected Summary - Only if not asStep or if specifically desired */} - {!asStep && isComplete && ( - - - Selection: - {selectedProject?.name} - / - {selectedPackage?.name} - / - - {selectedChainage?.segment_name} - - {selectedChainage?.direction === 'UP' ? ( - - ) : ( - - )} - {selectedChainage?.direction} - - - - - )} - - {/* Proceed Button */} - {!hideButton && ( - - {isComplete ? 'Proceed to Upload' : 'Select all fields to proceed'} - - )} - > - ); - - if (asStep) { - return content; - } - - return ( - - - - - - Select Project Segment - - - Select Project, Package & Segment to begin intelligent road - analysis. - - - - {/* Step Progress Indicator */} - - {[1, 2, 3].map((step, index) => { - const status = getStepStatus(step); - const labels = ['Project', 'Package', 'Segment']; - return ( - - - - {status === 'completed' ? ( - - ) : ( - step - )} - - - {labels[index]} - - - {index < 2 && ( - - )} - - ); - })} - - - - - {content} - - ); -} diff --git a/src/types/session.ts b/src/types/session.ts index 6a5c1b5..04daccc 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -11,12 +11,3 @@ export interface SessionContext { chainageDirection?: string | null; } -export const emptySessionContext: SessionContext = { - projectId: null, - projectName: null, - packageId: null, - packageName: null, - chainageId: null, - chainageName: null, - chainageDirection: null, -};
+ Select the AI model workflow for this job. +
+ Attach the road video and GPS telemetry for processing. +
+ Select the project hierarchy for this analysis job. +
{title}
{description}