diff --git a/src/app/(modules)/package/components/PackageDialog.tsx b/src/app/(modules)/package/components/PackageDialog.tsx index b40a1df..b56154a 100644 --- a/src/app/(modules)/package/components/PackageDialog.tsx +++ b/src/app/(modules)/package/components/PackageDialog.tsx @@ -1,10 +1,12 @@ 'use client'; import type { ComponentProps } from 'react'; +import { useRef } from 'react'; import type { FieldErrors, UseFormRegister } from 'react-hook-form'; import { Loader2 } from 'lucide-react'; import { FormField } from '@/components/form'; +import { ProjectSelect } from '@/components/lookups/ProjectSelect'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -15,14 +17,6 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import type { Project } from '@/types'; import type { PackageFormValues } from '../hooks/usePackageForm'; @@ -30,8 +24,6 @@ interface PackageDialogProps { open: boolean; onOpenChange: (open: boolean) => void; packageId?: string; - projects: Project[]; - isProjectsLoading: boolean; projectId: string; onProjectChange: (projectId: string) => void; register: UseFormRegister; @@ -46,8 +38,6 @@ export function PackageDialog({ open, onOpenChange, packageId, - projects, - isProjectsLoading, projectId, onProjectChange, register, @@ -57,6 +47,7 @@ export function PackageDialog({ canSubmit, isSaving, }: PackageDialogProps) { + const projectSelectPortalRef = useRef(null); const projectErrorMessage = isSubmitted ? errors.project_id?.message : undefined; @@ -95,25 +86,13 @@ export function PackageDialog({ {!packageId ? ( - + + +
); diff --git a/src/app/(modules)/package/page.tsx b/src/app/(modules)/package/page.tsx index 6bff39f..82aeb67 100644 --- a/src/app/(modules)/package/page.tsx +++ b/src/app/(modules)/package/page.tsx @@ -113,8 +113,6 @@ export default function PackagePage() { open={isDialogOpen} onOpenChange={handleDialogOpenChange} packageId={packageId} - projects={projects} - isProjectsLoading={projectsQuery.isLoading} projectId={projectId} onProjectChange={setProjectId} register={register} diff --git a/src/app/(modules)/package/queries/packageKeys.ts b/src/app/(modules)/package/queries/packageKeys.ts index 9a87c52..f9729aa 100644 --- a/src/app/(modules)/package/queries/packageKeys.ts +++ b/src/app/(modules)/package/queries/packageKeys.ts @@ -4,4 +4,6 @@ export const packageKeys = { all: ['packages'] as const, lists: () => [...packageKeys.all, 'list'] as const, list: (params: PaginationParams) => [...packageKeys.lists(), params] as const, + details: () => [...packageKeys.all, 'detail'] as const, + detail: (id: string) => [...packageKeys.details(), id] as const, }; diff --git a/src/app/(modules)/project/queries/projectKeys.ts b/src/app/(modules)/project/queries/projectKeys.ts index 1d3d374..39a030b 100644 --- a/src/app/(modules)/project/queries/projectKeys.ts +++ b/src/app/(modules)/project/queries/projectKeys.ts @@ -4,4 +4,6 @@ export const projectKeys = { all: ['projects'] as const, lists: () => [...projectKeys.all, 'list'] as const, list: (params: PaginationParams) => [...projectKeys.lists(), params] as const, + details: () => [...projectKeys.all, 'detail'] as const, + detail: (id: string) => [...projectKeys.details(), id] as const, }; diff --git a/src/app/(modules)/results/[videoId]/page.tsx b/src/app/(modules)/results/[videoId]/page.tsx index 9c73641..37f5ef8 100644 --- a/src/app/(modules)/results/[videoId]/page.tsx +++ b/src/app/(modules)/results/[videoId]/page.tsx @@ -1,16 +1,11 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; -import { useRouter, useParams } from 'next/navigation'; -import { Button } from '@/components/ui/button'; +import { useMemo } from 'react'; +import { useParams } from 'next/navigation'; import { Loader2, TrendingUp } from 'lucide-react'; import VideoPlayerSection from '@/components/videoPlayerSection'; import { PageHeader } from '@/components/page-header'; -import { sessionService } from '@/services/api'; -import { SessionContext, CompletedVideoResult, DetectionType } from '@/types'; -import { clearVideoFile } from '@/lib/video-storage'; -import { ROUTES } from '@/utils/routes'; -import { Card } from '@/components/ui/card'; +import { CompletedVideoResult, DetectionType } from '@/types'; import { getDetectionModeConfig } from '@/constants/detectionModeConfig'; import { useVideoResultsQuery } from '../hooks/useVideoResults'; @@ -26,9 +21,7 @@ const inferDetectionType = (data: CompletedVideoResult): DetectionType => { }; export default function VideoResultsPage() { - const router = useRouter(); const { videoId } = useParams() as { videoId: string }; - const [session, setSession] = useState(null); const { data: detectionData, @@ -48,22 +41,6 @@ export default function VideoResultsPage() { : 'Failed to load results' : null; - useEffect(() => { - setSession(sessionService.loadSession()); - }, []); - - const handleNewAnalysis = async () => { - if (videoId) { - try { - await clearVideoFile(videoId); - } catch (err) { - console.error('Failed to clear video file:', err); - } - } - sessionService.clearSession(); - router.push(ROUTES.UPLOAD); - }; - const getTitle = () => { const config = getDetectionModeConfig(detectionType); return `${config.label} Results`; @@ -71,55 +48,29 @@ export default function VideoResultsPage() { if (isLoading) { return ( -
- - -

- Loading detection results... -

-
+
+
); } if (error) { return ( -
- -

{error}

-
- - -
-
+
+

{error}

); } return ( -
-
-
-
- -
+
+ - {detectionData && ( - - )} -
-
+ {detectionData && }
); } diff --git a/src/app/(modules)/results/page.tsx b/src/app/(modules)/results/page.tsx deleted file mode 100644 index 4eafaac..0000000 --- a/src/app/(modules)/results/page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { Loader2 } from 'lucide-react'; -import { Card } from '@/components/ui/card'; -import { sessionService } from '@/services/api'; -import { ROUTES } from '@/utils/routes'; - -export default function ResultsPage() { - const router = useRouter(); - - useEffect(() => { - const storedSession = sessionService.loadSession(); - const videoData = sessionService.loadVideoData(); - - if (!sessionService.isSessionValid(storedSession) || !videoData?.videoId) { - router.replace(ROUTES.UPLOAD); - return; - } - - router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`); - }, [router]); - - return ( -
- - -

Loading results...

-
-
- ); -} diff --git a/src/app/(modules)/segment/components/SegmentDialog.tsx b/src/app/(modules)/segment/components/SegmentDialog.tsx index 56bc5e8..6695b82 100644 --- a/src/app/(modules)/segment/components/SegmentDialog.tsx +++ b/src/app/(modules)/segment/components/SegmentDialog.tsx @@ -1,10 +1,13 @@ 'use client'; import type { ComponentProps } from 'react'; +import { useRef } from 'react'; import type { FieldErrors, UseFormRegister } from 'react-hook-form'; import { Loader2 } from 'lucide-react'; import { FormField } from '@/components/form'; +import { PackageSelect } from '@/components/lookups/PackageSelect'; +import { ProjectSelect } from '@/components/lookups/ProjectSelect'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -22,7 +25,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import type { Package, Project } from '@/types'; import type { SegmentFormValues } from '../hooks/useSegmentForm'; @@ -30,10 +32,6 @@ interface SegmentDialogProps { open: boolean; onOpenChange: (open: boolean) => void; segmentId?: string; - projects: Project[]; - packages: Package[]; - isProjectsLoading: boolean; - isPackagesLoading: boolean; projectId: string; onProjectChange: (projectId: string) => void; packageId: string; @@ -52,10 +50,6 @@ export function SegmentDialog({ open, onOpenChange, segmentId, - projects, - packages, - isProjectsLoading, - isPackagesLoading, projectId, onProjectChange, packageId, @@ -69,6 +63,8 @@ export function SegmentDialog({ canSubmit, isSaving, }: SegmentDialogProps) { + const projectSelectPortalRef = useRef(null); + const packageSelectPortalRef = useRef(null); const getError = (field: keyof SegmentFormValues) => isSubmitted ? errors[field]?.message : undefined; @@ -104,25 +100,13 @@ export function SegmentDialog({ required error={getError('project_id')} > - + - + projectId={projectId} + enabled={open && Boolean(projectId)} + disabled={!projectId || isSaving} + portalContainer={packageSelectPortalRef} + /> + +
+
); diff --git a/src/app/(modules)/segment/hooks/useSegmentQueries.ts b/src/app/(modules)/segment/hooks/useSegmentQueries.ts index 31051c8..0a9a364 100644 --- a/src/app/(modules)/segment/hooks/useSegmentQueries.ts +++ b/src/app/(modules)/segment/hooks/useSegmentQueries.ts @@ -44,15 +44,6 @@ 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 }), - enabled: enabled && Boolean(projectId), - }); -} - export function useDeleteSegmentMutation() { const queryClient = useQueryClient(); diff --git a/src/app/(modules)/segment/page.tsx b/src/app/(modules)/segment/page.tsx index 5006dd5..8a0f52e 100644 --- a/src/app/(modules)/segment/page.tsx +++ b/src/app/(modules)/segment/page.tsx @@ -15,7 +15,6 @@ import { useSegmentForm } from './hooks/useSegmentForm'; import { useAllPackageOptionsQuery, useDeleteSegmentMutation, - usePackagesByProjectQuery, useProjectOptionsQuery, useSegmentsQuery, } from './hooks/useSegmentQueries'; @@ -48,11 +47,6 @@ export default function SegmentPage() { canSubmit, isSaving, } = segmentForm; - const projectPackagesQuery = usePackagesByProjectQuery( - projectId, - isDialogOpen, - ); - const openCreate = useCallback(() => { prepareCreateSegment(); setIsDialogOpen(true); @@ -95,7 +89,6 @@ export default function SegmentPage() { const projects = projectsQuery.data?.items ?? []; const allPackages = allPackagesQuery.data?.items ?? []; - const projectPackages = projectPackagesQuery.data?.items ?? []; const segments = segmentsQuery.data?.items ?? []; const total = segmentsQuery.data?.totalItems ?? 0; const columns = useSegmentColumns(projects, allPackages); @@ -133,10 +126,6 @@ export default function SegmentPage() { open={isDialogOpen} onOpenChange={handleDialogOpenChange} segmentId={segmentId} - projects={projects} - packages={projectPackages} - isProjectsLoading={projectsQuery.isLoading} - isPackagesLoading={projectPackagesQuery.isLoading} projectId={projectId} onProjectChange={setProjectId} packageId={packageId} diff --git a/src/app/(modules)/segment/queries/segmentKeys.ts b/src/app/(modules)/segment/queries/segmentKeys.ts index 65b4c19..597a59c 100644 --- a/src/app/(modules)/segment/queries/segmentKeys.ts +++ b/src/app/(modules)/segment/queries/segmentKeys.ts @@ -4,4 +4,6 @@ export const segmentKeys = { all: ['segments'] as const, lists: () => [...segmentKeys.all, 'list'] as const, list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const, + details: () => [...segmentKeys.all, 'detail'] as const, + detail: (id: string) => [...segmentKeys.details(), id] as const, }; diff --git a/src/app/(modules)/ticket/page.tsx b/src/app/(modules)/ticket/page.tsx new file mode 100644 index 0000000..946a97b --- /dev/null +++ b/src/app/(modules)/ticket/page.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { PageHeader } from '@/components/page-header'; +import { Ticket } from 'lucide-react'; + +export default function TicketPage() { + return ( +
+
+
+ +
+ + + + Ticket Page + + +

+ This is ticket page for now. +

+
+
+
+
+ ); +} diff --git a/src/app/(modules)/upload/[videoId]/page.tsx b/src/app/(modules)/upload/[videoId]/page.tsx deleted file mode 100644 index e12900c..0000000 --- a/src/app/(modules)/upload/[videoId]/page.tsx +++ /dev/null @@ -1,260 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useRouter, useParams } from 'next/navigation'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react'; -import { PageHeader } from '@/components/page-header'; -import { sessionService, videoService } from '@/services/api'; -import { SessionContext } from '@/types'; -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://', -); - -export default function VideoProcessingPage() { - const router = useRouter(); - const { videoId } = useParams() as { videoId: string }; - const [session, setSession] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - // Processing states - const [progress, setProgress] = useState(0); - const [statusMessage, setStatusMessage] = useState('Initializing...'); - const [error, setError] = useState(null); - - const wsRef = useRef(null); - - const connectWebSocket = useCallback( - (vid: string) => { - if (wsRef.current) return wsRef.current; - - const ws = new WebSocket(`${WS_URL}/ws/${vid}`); - wsRef.current = ws; - - ws.onmessage = async (event) => { - const data = JSON.parse(event.data); - - if (data.type === 'progress' || data.progress !== undefined) { - 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_drain_issue; - if (uniqueCount !== undefined) { - message += ` | Unique: ${uniqueCount} | Total: ${data.total_detections || 0}`; - } - setStatusMessage(message); - } - - if (data.type === 'complete' || data.status === 'completed') { - setStatusMessage('Processing completed! Finalizing...'); - ws.close(); - - // Navigate to results - setTimeout(() => router.push(`/results/${vid}`), 1000); - } - - if (data.type === 'error') { - setError('Error: ' + data.message); - setStatusMessage(''); - ws.close(); - } - }; - - ws.onerror = () => { - setStatusMessage('Connection lost. Reconnecting...'); - wsRef.current = null; - setTimeout(() => connectWebSocket(vid), 3000); - }; - - ws.onclose = () => { - wsRef.current = null; - }; - - return ws; - }, - [router], - ); - - useEffect(() => { - const storedSession = sessionService.loadSession(); - setSession(storedSession); - - let isMounted = true; - - const checkStatus = async () => { - try { - const statusData = await videoService.getVideoStatus(videoId); - - if (!isMounted) return; - - if (statusData.status === 'completed') { - router.replace(`/results/${videoId}`); - return; - } - - if (statusData.status === 'error') { - setError( - statusData.message || 'An error occurred during processing.', - ); - setIsLoading(false); - return; - } - - // If processing, start WebSocket - setProgress(statusData.progress || 0); - setStatusMessage(statusData.message || 'Resuming processing...'); - connectWebSocket(videoId); - setIsLoading(false); - } catch (err) { - console.error('Status check failed:', err); - if (isMounted) { - setError('Failed to connect to server.'); - setIsLoading(false); - } - } - }; - - if (videoId) { - checkStatus(); - } - - return () => { - isMounted = false; - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, [videoId, router, connectWebSocket]); - - if (isLoading) { - return ( -
- - - -
- ); - } - - return ( -
-
-
-
- -
- - {session && ( -
- -
-
-
- - Project - - - {session.projectName} - -
-
- - Package - - - {session.packageName} - -
-
- - Segment - - - {session.chainageName} - {session.chainageDirection && ( - - {session.chainageDirection === 'UP' ? ( - - ) : ( - - )} - {session.chainageDirection} - - )} - -
-
-
-
-
- )} - - -
-
-

- Uploading ... -

-

- ID: {videoId} -

-
- -
-
- - Progress - - - {progress}% - -
-
-
-
-
-
- -

{statusMessage}

-
-
-
- - {error && ( -
-

{error}

- -
- )} -
- -
-
-
- ); -} diff --git a/src/app/(modules)/upload/page.tsx b/src/app/(modules)/upload/page.tsx index 46ad362..e4a802d 100644 --- a/src/app/(modules)/upload/page.tsx +++ b/src/app/(modules)/upload/page.tsx @@ -9,9 +9,9 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; +import { FormField } from '@/components/form'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Select, SelectContent, @@ -19,14 +19,14 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Loader2, TrendingUp, ChevronRight } from 'lucide-react'; +import { Loader2, TrendingUp } from 'lucide-react'; import { PageHeader } from '@/components/page-header'; -import { sessionService, videoService } from '@/services/api'; +import { videoService } from '@/services/api'; import { SessionContext } from '@/types'; -import { storeVideoFile } from '@/lib/video-storage'; import { ProjectSelectionSection } from '@/components/project-selection-section'; import { Reveal } from '@/components/ui/reveal'; import { cn } from '@/lib/utils'; +import { ROUTES } from '@/utils/routes'; const DETECTION_METHODS = [ { value: 'yolo', label: 'Road Defect Detection' }, @@ -63,9 +63,6 @@ export default function UploadPage() { const handleSelectionChange = useCallback( (selectedSession: SessionContext | null) => { setSession(selectedSession); - if (selectedSession) { - sessionService.saveSession(selectedSession); - } }, [], ); @@ -96,19 +93,8 @@ export default function UploadPage() { setError(null); try { - const result = await videoService.uploadVideo(formData); - - // Store file locally - if (file) { - try { - await storeVideoFile(result.video_id, file); - } catch (err) { - console.error('Failed to store video file:', err); - } - } - - sessionService.saveVideoData({ videoId: result.video_id }); - router.push(`/upload/${result.video_id}`); + await videoService.uploadVideo(formData); + router.push(ROUTES.TICKET); } catch (err) { let errorMessage = 'Upload failed'; if (err instanceof TypeError && err.message === 'Failed to fetch') { @@ -131,7 +117,7 @@ export default function UploadPage() { } const isSessionComplete = !!( - session && sessionService.isSessionValid(session) + session?.projectId && session.packageId && session.chainageId ); const isFormValid = isSessionComplete && file && jsonFile; @@ -187,40 +173,22 @@ export default function UploadPage() { -
- {/* Video File Input */} -
- -
- { - setFile(e.target.files?.[0] || null); - setError(null); - }} - disabled={uploading || !isSessionComplete} - className="h-12 bg-muted/30 border-dashed border-2 group-hover:border-primary/50 transition-colors cursor-pointer file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary file:text-primary-foreground file:font-semibold file:text-xs" - /> -
-
+
+ + { + setFile(e.target.files?.[0] || null); + setError(null); + }} + disabled={uploading || !isSessionComplete} + /> +
- {/* JSON File Input */} -
- + -
+ - {/* Select Method */} -
- + -
+
@@ -273,23 +230,19 @@ export default function UploadPage() {
)} - {/* Primary Action Button */} -
+
diff --git a/src/components/lookups/PackageSelect.tsx b/src/components/lookups/PackageSelect.tsx new file mode 100644 index 0000000..df2cd75 --- /dev/null +++ b/src/components/lookups/PackageSelect.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { AsyncSelect, usePaginatedSelect } from '@/components/async-select'; +import type { AsyncSelectOption } from '@/components/async-select'; +import { packageService } from '@/services/api'; +import type { Package } from '@/types'; + +import { packageKeys } from '@/app/(modules)/package/queries/packageKeys'; +import type { PackageSelectProps } from './PackageSelect.types'; + +const PACKAGE_PAGE_SIZE = 20; + +function mapPackageOption(pkg: Package): AsyncSelectOption { + return { + value: pkg.id, + label: pkg.name, + }; +} + +export function PackageSelect({ + value, + onValueChange, + projectId, + enabled = true, + disabled = false, + portalContainer, +}: PackageSelectProps) { + const lookup = usePaginatedSelect({ + enabled, + selectedValue: value, + pageSize: PACKAGE_PAGE_SIZE, + queryKey: (searchTerm) => + [ + ...packageKeys.lists(), + 'async-lookup', + projectId || 'all', + searchTerm, + PACKAGE_PAGE_SIZE, + ] as const, + queryFn: async ({ searchTerm, skip, limit }) => { + const params = { + search_term: searchTerm || undefined, + skip, + limit, + }; + const data = projectId + ? await packageService.getPackagesByProject(projectId, params) + : await packageService.getPackages(params); + + return { items: data.items, total: data.totalItems }; + }, + mapOption: mapPackageOption, + resolveSelected: (packageId) => + packageId ? packageService.getPackageById(packageId) : Promise.resolve(null), + resolveSelectedQueryKey: (packageId) => + packageId + ? packageKeys.detail(packageId) + : [...packageKeys.details(), 'invalid', packageId], + }); + + return ( + + ); +} diff --git a/src/components/lookups/PackageSelect.types.ts b/src/components/lookups/PackageSelect.types.ts new file mode 100644 index 0000000..b672039 --- /dev/null +++ b/src/components/lookups/PackageSelect.types.ts @@ -0,0 +1,10 @@ +import type { RefObject } from 'react'; + +export interface PackageSelectProps { + value: string; + onValueChange: (value: string) => void; + projectId?: string; + enabled?: boolean; + disabled?: boolean; + portalContainer?: RefObject; +} diff --git a/src/components/lookups/ProjectSelect.tsx b/src/components/lookups/ProjectSelect.tsx new file mode 100644 index 0000000..f864535 --- /dev/null +++ b/src/components/lookups/ProjectSelect.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { AsyncSelect, usePaginatedSelect } from '@/components/async-select'; +import type { AsyncSelectOption } from '@/components/async-select'; +import { projectService } from '@/services/api'; +import type { Project } from '@/types'; + +import { projectKeys } from '@/app/(modules)/project/queries/projectKeys'; +import type { ProjectSelectProps } from './ProjectSelect.types'; + +const PROJECT_PAGE_SIZE = 20; + +function mapProjectOption(project: Project): AsyncSelectOption { + return { + value: project.id, + label: project.name, + }; +} + +export function ProjectSelect({ + value, + onValueChange, + enabled = true, + disabled = false, + portalContainer, +}: ProjectSelectProps) { + const lookup = usePaginatedSelect({ + enabled, + selectedValue: value, + pageSize: PROJECT_PAGE_SIZE, + queryKey: (searchTerm) => + [ + ...projectKeys.lists(), + 'async-lookup', + searchTerm, + PROJECT_PAGE_SIZE, + ] as const, + queryFn: async ({ searchTerm, skip, limit }) => { + const data = await projectService.getProjects({ + search_term: searchTerm || undefined, + skip, + limit, + }); + + return { items: data.items, total: data.totalItems }; + }, + mapOption: mapProjectOption, + resolveSelected: (projectId) => + projectId ? projectService.getProjectById(projectId) : Promise.resolve(null), + resolveSelectedQueryKey: (projectId) => + projectId + ? projectKeys.detail(projectId) + : [...projectKeys.details(), 'invalid', projectId], + }); + + return ( + + ); +} diff --git a/src/components/lookups/ProjectSelect.types.ts b/src/components/lookups/ProjectSelect.types.ts new file mode 100644 index 0000000..88c9c3a --- /dev/null +++ b/src/components/lookups/ProjectSelect.types.ts @@ -0,0 +1,9 @@ +import type { RefObject } from 'react'; + +export interface ProjectSelectProps { + value: string; + onValueChange: (value: string) => void; + enabled?: boolean; + disabled?: boolean; + portalContainer?: RefObject; +} diff --git a/src/components/lookups/SegmentSelect.tsx b/src/components/lookups/SegmentSelect.tsx new file mode 100644 index 0000000..14fa6fb --- /dev/null +++ b/src/components/lookups/SegmentSelect.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { AsyncSelect, usePaginatedSelect } from '@/components/async-select'; +import type { AsyncSelectOption } from '@/components/async-select'; +import { chainageService } from '@/services/api'; +import type { Chainage } from '@/types'; + +import { segmentKeys } from '@/app/(modules)/segment/queries/segmentKeys'; +import type { SegmentSelectProps } from './SegmentSelect.types'; + +const SEGMENT_PAGE_SIZE = 20; + +function mapSegmentOption(segment: Chainage): AsyncSelectOption { + return { + value: segment.id, + label: segment.segment_name, + }; +} + +export function SegmentSelect({ + value, + onValueChange, + packageId, + enabled = true, + disabled = false, + portalContainer, +}: SegmentSelectProps) { + const lookup = usePaginatedSelect({ + enabled, + selectedValue: value, + pageSize: SEGMENT_PAGE_SIZE, + queryKey: (searchTerm) => + [ + ...segmentKeys.lists(), + 'async-lookup', + packageId || 'all', + searchTerm, + SEGMENT_PAGE_SIZE, + ] as const, + queryFn: async ({ searchTerm, skip, limit }) => { + const params = { + search_term: searchTerm || undefined, + skip, + limit, + }; + const data = packageId + ? await chainageService.getChainagesByPackage(packageId, params) + : await chainageService.getChainages(params); + + return { items: data.items, total: data.totalItems }; + }, + mapOption: mapSegmentOption, + resolveSelected: (segmentId) => + segmentId + ? chainageService.getChainageById(segmentId) + : Promise.resolve(null), + resolveSelectedQueryKey: (segmentId) => + segmentId + ? segmentKeys.detail(segmentId) + : [...segmentKeys.details(), 'invalid', segmentId], + }); + + return ( + + ); +} diff --git a/src/components/lookups/SegmentSelect.types.ts b/src/components/lookups/SegmentSelect.types.ts new file mode 100644 index 0000000..afe34ca --- /dev/null +++ b/src/components/lookups/SegmentSelect.types.ts @@ -0,0 +1,10 @@ +import type { RefObject } from 'react'; + +export interface SegmentSelectProps { + value: string; + onValueChange: (value: string) => void; + packageId?: string; + enabled?: boolean; + disabled?: boolean; + portalContainer?: RefObject; +} diff --git a/src/components/project-selection-section.tsx b/src/components/project-selection-section.tsx index a63a96c..2481fbc 100644 --- a/src/components/project-selection-section.tsx +++ b/src/components/project-selection-section.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Card, CardContent, @@ -10,14 +10,10 @@ import { } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Loader2, Check, ArrowUp, ArrowDown } from 'lucide-react'; +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, @@ -38,23 +34,15 @@ type ProjectSelectionSectionProps = { hideButton?: boolean; }; -let globalProjectsCache: Project[] | null = null; -let projectsPromise: Promise | null = null; - export function ProjectSelectionSection({ onSelectionComplete, onSelectionChange, asStep = false, hideButton = false, }: ProjectSelectionSectionProps) { - // Data states - const [projects, setProjects] = useState( - globalProjectsCache || [], - ); - const [packages, setPackages] = useState([]); - const [chainages, setChainages] = useState([]); - - // Selection states + const [selectedProjectId, setSelectedProjectId] = useState(''); + const [selectedPackageId, setSelectedPackageId] = useState(''); + const [selectedChainageId, setSelectedChainageId] = useState(''); const [selectedProject, setSelectedProject] = useState(null); const [selectedPackage, setSelectedPackage] = useState( null, @@ -62,159 +50,99 @@ export function ProjectSelectionSection({ const [selectedChainage, setSelectedChainage] = useState( null, ); - - // Loading states - const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache); - const [loadingPackages, setLoadingPackages] = useState(false); - const [loadingChainages, setLoadingChainages] = useState(false); - - // Error state const [error, setError] = useState(null); - // Load projects on mount - useEffect(() => { - let isMounted = true; + const lastReportedIdRef = useRef(null); + const latestProjectIdRef = useRef(''); + const latestPackageIdRef = useRef(''); + const latestChainageIdRef = useRef(''); - const loadProjects = async () => { - // Use cache if we already have it - if (globalProjectsCache) { - if (isMounted) { - setProjects(globalProjectsCache); - setLoadingProjects(false); - } - return; - } + const handleProjectChange = useCallback((projectId: string) => { + latestProjectIdRef.current = projectId; + latestPackageIdRef.current = ''; + latestChainageIdRef.current = ''; - // 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; - }); - } + setSelectedProjectId(projectId); + setSelectedPackageId(''); + setSelectedChainageId(''); + setSelectedProject(null); + setSelectedPackage(null); + setSelectedChainage(null); + setError(null); - try { - setLoadingProjects(true); - const data = await projectsPromise; - if (isMounted) { - setProjects(data); - } - } catch (err) { - if (isMounted) { - setError('Failed to load projects. Please check connection.'); - } - } finally { - if (isMounted) { - setLoadingProjects(false); - } - } - }; + if (!projectId) { + return; + } - loadProjects(); - return () => { - isMounted = false; - }; + 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.'); + } + }); }, []); - // Load packages when project changes - useEffect(() => { - if (!selectedProject) { - setPackages([]); - setSelectedPackage(null); + const handlePackageChange = useCallback((packageId: string) => { + latestPackageIdRef.current = packageId; + latestChainageIdRef.current = ''; + + setSelectedPackageId(packageId); + setSelectedChainageId(''); + setSelectedPackage(null); + setSelectedChainage(null); + setError(null); + + if (!packageId) { return; } - let isMounted = true; - const loadPackages = async () => { - try { - setLoadingPackages(true); - setError(null); - setSelectedPackage(null); - setSelectedChainage(null); - setChainages([]); - const data = await packageService.getPackagesByProject( - selectedProject.id, - ); - if (isMounted) { - setPackages(data.items); + void packageService + .getPackageById(packageId) + .then((pkg) => { + if (latestPackageIdRef.current === packageId) { + setSelectedPackage(pkg); } - } catch (err) { - if (isMounted) { - console.error('Failed to load packages:', err); - setError('Failed to load packages for the selected project.'); + }) + .catch((err) => { + if (latestPackageIdRef.current === packageId) { + console.error('Failed to resolve package:', err); + setError('Failed to load the selected package.'); } - } finally { - if (isMounted) { - setLoadingPackages(false); - } - } - }; - loadPackages(); - return () => { - isMounted = false; - }; - }, [selectedProject]); + }); + }, []); - // Load chainages when package changes - useEffect(() => { - if (!selectedPackage) { - setChainages([]); - setSelectedChainage(null); + const handleChainageChange = useCallback((chainageId: string) => { + latestChainageIdRef.current = chainageId; + + setSelectedChainageId(chainageId); + setSelectedChainage(null); + setError(null); + + if (!chainageId) { return; } - let isMounted = true; - const loadChainages = async () => { - try { - setLoadingChainages(true); - setError(null); - setSelectedChainage(null); - const data = await chainageService.getChainagesByPackage( - selectedPackage.id, - ); - if (isMounted) { - setChainages(data.items); + void chainageService + .getChainageById(chainageId) + .then((chainage) => { + if (latestChainageIdRef.current === chainageId) { + setSelectedChainage(chainage); } - } catch (err) { - if (isMounted) { - console.error('Failed to load chainages:', err); - setError('Failed to load segments for the selected package.'); + }) + .catch((err) => { + if (latestChainageIdRef.current === chainageId) { + console.error('Failed to resolve segment:', err); + setError('Failed to load the selected segment.'); } - } finally { - if (isMounted) { - setLoadingChainages(false); - } - } - }; - loadChainages(); - return () => { - isMounted = false; - }; - }, [selectedPackage]); - - const handleProjectChange = (projectId: string) => { - const project = projects.find((p) => p.id === projectId) || null; - setSelectedProject(project); - }; - - const handlePackageChange = (packageId: string) => { - const pkg = packages.find((p) => p.id === packageId) || null; - setSelectedPackage(pkg); - }; - - const handleChainageChange = (chainageId: string) => { - const chainage = chainages.find((chn) => chn.id === chainageId) || null; - setSelectedChainage(chainage); - }; - - // Cache the last reported state to prevent infinite loops - const lastReportedIdRef = useRef(null); + }); + }, []); // Handle change reporting useEffect(() => { @@ -267,17 +195,17 @@ export function ProjectSelectionSection({ // Step status helpers const getStepStatus = (step: number) => { - if (step === 1) return selectedProject ? 'completed' : 'active'; + if (step === 1) return selectedProjectId ? 'completed' : 'active'; if (step === 2) - return selectedPackage + return selectedPackageId ? 'completed' - : selectedProject + : selectedProjectId ? 'active' : 'pending'; if (step === 3) - return selectedChainage + return selectedChainageId ? 'completed' - : selectedPackage + : selectedPackageId ? 'active' : 'pending'; return 'pending'; @@ -293,120 +221,41 @@ export function ProjectSelectionSection({ )}
- {/* Project Dropdown */}
- + enabled + />
- {/* Package Dropdown */}
- + projectId={selectedProjectId} + enabled={Boolean(selectedProjectId)} + disabled={!selectedProjectId} + />
- {/* Chainage Dropdown */}
- + packageId={selectedPackageId} + enabled={Boolean(selectedPackageId)} + disabled={!selectedPackageId} + />
diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts index 53173e1..86e429f 100644 --- a/src/constants/apiRoutes.ts +++ b/src/constants/apiRoutes.ts @@ -51,9 +51,7 @@ export const API_ROUTES = { OVERVIEW: 'biz/api/v1/dashboard/overview', }, VIDEOS: { - LIST: '/videos', UPLOAD: '/biz/api/v1/upload', - STATUS: (id: string) => `/status/${id}`, RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`, }, } as const; diff --git a/src/lib/video-storage.ts b/src/lib/video-storage.ts deleted file mode 100644 index 9345651..0000000 --- a/src/lib/video-storage.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * IndexedDB storage for video files - * Used to persist video files across page navigations - */ - -const DB_NAME = 'visionroad_db'; -const DB_VERSION = 1; -const VIDEO_STORE = 'videos'; - -let db: IDBDatabase | null = null; - -/** - * Open the IndexedDB database - */ -async function openDB(): Promise { - if (db) return db; - - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - db = request.result; - resolve(db); - }; - - request.onupgradeneeded = (event) => { - const database = (event.target as IDBOpenDBRequest).result; - if (!database.objectStoreNames.contains(VIDEO_STORE)) { - database.createObjectStore(VIDEO_STORE, { keyPath: 'id' }); - } - }; - }); -} - -/** - * Store a video file in IndexedDB - */ -export async function storeVideoFile( - videoId: string, - file: File, -): Promise { - const database = await openDB(); - - return new Promise((resolve, reject) => { - const transaction = database.transaction([VIDEO_STORE], 'readwrite'); - const store = transaction.objectStore(VIDEO_STORE); - - const request = store.put({ - id: videoId, - file: file, - timestamp: Date.now(), - }); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); -} - -/** - * Retrieve a video file from IndexedDB - */ -export async function getVideoFile(videoId: string): Promise { - const database = await openDB(); - - return new Promise((resolve, reject) => { - const transaction = database.transaction([VIDEO_STORE], 'readonly'); - const store = transaction.objectStore(VIDEO_STORE); - - const request = store.get(videoId); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const result = request.result; - resolve(result ? result.file : null); - }; - }); -} - -/** - * Clear a video file from IndexedDB - */ -export async function clearVideoFile(videoId: string): Promise { - const database = await openDB(); - - return new Promise((resolve, reject) => { - const transaction = database.transaction([VIDEO_STORE], 'readwrite'); - const store = transaction.objectStore(VIDEO_STORE); - - const request = store.delete(videoId); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); -} - -/** - * Clear all video files from IndexedDB - */ -export async function clearAllVideos(): Promise { - const database = await openDB(); - - return new Promise((resolve, reject) => { - const transaction = database.transaction([VIDEO_STORE], 'readwrite'); - const store = transaction.objectStore(VIDEO_STORE); - - const request = store.clear(); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); -} diff --git a/src/services/api/chainage.service.ts b/src/services/api/chainage.service.ts index 6057678..0fe97d1 100644 --- a/src/services/api/chainage.service.ts +++ b/src/services/api/chainage.service.ts @@ -16,14 +16,14 @@ export const chainageService = { * Fetch all chainages */ getChainages: async ( - params?: PaginationParams, + params?: PaginationParams & { search_term?: string }, ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; const response = await axiosClient.get>( API_ROUTES.CHAINAGES.BASE, { - params: { skip, limit }, + params: { skip, limit, search_term: params?.search_term }, }, ); return response.data; @@ -34,19 +34,34 @@ export const chainageService = { */ getChainagesByPackage: async ( packageId: string, - params?: PaginationParams, + params?: PaginationParams & { search_term?: string }, ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; const response = await axiosClient.get>( API_ROUTES.CHAINAGES.BASE, { - params: { package_id: packageId, skip, limit }, + params: { + package_id: packageId, + skip, + limit, + search_term: params?.search_term, + }, }, ); return response.data; }, + /** + * Fetch a chainage by ID + */ + getChainageById: async (chainageId: string): Promise => { + const response = await axiosClient.get( + API_ROUTES.CHAINAGES.DETAIL(chainageId), + ); + return response.data; + }, + /** * Create a new chainage */ diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 9389cc4..320e3ff 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -5,7 +5,6 @@ export * from './auth.service'; export { chainageService } from './chainage.service'; export * from './video.service'; export * from './detection.service'; -export * from './session.service'; export * from './permission.service'; export * from './role.service'; export * from './user.service'; diff --git a/src/services/api/package.service.ts b/src/services/api/package.service.ts index 353d5cf..d6f2d27 100644 --- a/src/services/api/package.service.ts +++ b/src/services/api/package.service.ts @@ -16,14 +16,14 @@ export const packageService = { * Fetch all packages */ getPackages: async ( - params?: PaginationParams, + params?: PaginationParams & { search_term?: string }, ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; const response = await axiosClient.get>( API_ROUTES.PACKAGES.BASE, { - params: { skip, limit }, + params: { skip, limit, search_term: params?.search_term }, }, ); return response.data; @@ -34,19 +34,34 @@ export const packageService = { */ getPackagesByProject: async ( projectId: string, - params?: PaginationParams, + params?: PaginationParams & { search_term?: string }, ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; const response = await axiosClient.get>( API_ROUTES.PACKAGES.BASE, { - params: { project_id: projectId, skip, limit }, + params: { + project_id: projectId, + skip, + limit, + search_term: params?.search_term, + }, }, ); return response.data; }, + /** + * Fetch a package by ID + */ + getPackageById: async (packageId: string): Promise => { + const response = await axiosClient.get( + API_ROUTES.PACKAGES.DETAIL(packageId), + ); + return response.data; + }, + /** * Create a new package */ diff --git a/src/services/api/project.service.ts b/src/services/api/project.service.ts index d5f3d43..871d0d3 100644 --- a/src/services/api/project.service.ts +++ b/src/services/api/project.service.ts @@ -16,19 +16,29 @@ export const projectService = { * Fetch all projects */ getProjects: async ( - params?: PaginationParams, + params?: PaginationParams & { search_term?: string }, ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; const response = await axiosClient.get>( API_ROUTES.PROJECTS.BASE, { - params: { skip, limit }, + params: { skip, limit, search_term: params?.search_term }, }, ); return response.data; }, + /** + * Fetch a project by ID + */ + getProjectById: async (projectId: string): Promise => { + const response = await axiosClient.get( + API_ROUTES.PROJECTS.DETAIL(projectId), + ); + return response.data; + }, + /** * Create a new project */ diff --git a/src/services/api/session.service.ts b/src/services/api/session.service.ts deleted file mode 100644 index 795b972..0000000 --- a/src/services/api/session.service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { SessionContext, VideoResultData, emptySessionContext } from '@/types'; - -// Session Storage Keys -const SESSION_KEY = 'visionroad_session'; -const VIDEO_DATA_KEY = 'visionroad_video_data'; -const DETECTION_TYPE_KEY = 'visionroad_detection_type'; - -/** - * Session Service - */ -export const sessionService = { - /** - * Save session to localStorage - */ - saveSession: (session: SessionContext): void => { - if (typeof window !== 'undefined') { - localStorage.setItem(SESSION_KEY, JSON.stringify(session)); - } - }, - - /** - * Load session from localStorage - */ - loadSession: (): SessionContext => { - if (typeof window !== 'undefined') { - const stored = localStorage.getItem(SESSION_KEY); - if (stored) { - return JSON.parse(stored); - } - } - return emptySessionContext; - }, - - /** - * Clear all session data - */ - clearSession: (): void => { - if (typeof window !== 'undefined') { - localStorage.removeItem(SESSION_KEY); - localStorage.removeItem(VIDEO_DATA_KEY); - localStorage.removeItem(DETECTION_TYPE_KEY); - } - }, - - /** - * Save video result data - */ - saveVideoData: (data: VideoResultData): void => { - if (typeof window !== 'undefined') { - localStorage.setItem(VIDEO_DATA_KEY, JSON.stringify(data)); - } - }, - - /** - * Load video result data - */ - loadVideoData: (): VideoResultData | null => { - if (typeof window !== 'undefined') { - const stored = localStorage.getItem(VIDEO_DATA_KEY); - if (stored) { - return JSON.parse(stored); - } - } - return null; - }, - - /** - * Check if session is complete - */ - isSessionValid: (session: SessionContext): boolean => { - return !!(session.projectId && session.packageId && session.chainageId); - }, -}; diff --git a/src/services/api/video.service.ts b/src/services/api/video.service.ts index b010b80..1a86bd0 100644 --- a/src/services/api/video.service.ts +++ b/src/services/api/video.service.ts @@ -1,55 +1,11 @@ import axiosClient from '../axios/axios'; import { API_ROUTES } from '@/constants/apiRoutes'; -import { CompletedVideoResult, Video, PaginationParams } from '@/types'; +import { CompletedVideoResult } from '@/types'; /** * Video Service */ export const videoService = { - /** - * Fetch all videos and transform them to match the Video interface - */ - getVideos: async (params?: PaginationParams): Promise => { - const skip = params?.skip ?? 0; - const limit = params?.limit ?? 100; - - const response = await axiosClient.get<{ - videos: Array<{ - video_id: string; - status: string; - progress: number; - summary?: { - unique_defected_sign_board?: number; - unique_pothole?: number; - unique_road_crack?: number; - unique_damaged_road_marking?: number; - unique_good_sign_board?: number; - total_road_damage?: number; - total_detections?: number; - }; - }>; - }>(API_ROUTES.VIDEOS.LIST, { - params: { skip, limit }, - }); - - // Transform the response to match our Video interface - return response.data.videos.map((v) => ({ - id: v.video_id, - filename: v.video_id, - detection_type: 'pot-sign-detection' as const, - status: v.status as Video['status'], - unique_defected_sign_board: v.summary?.unique_defected_sign_board, - unique_pothole: v.summary?.unique_pothole, - unique_road_crack: v.summary?.unique_road_crack, - unique_damaged_road_marking: v.summary?.unique_damaged_road_marking, - unique_good_sign_board: v.summary?.unique_good_sign_board, - total_road_damage: v.summary?.total_road_damage, - total_detections: v.summary?.total_detections, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - })); - }, - /** * Upload a video for processing */ @@ -66,14 +22,6 @@ export const videoService = { return response.data; }, - /** - * Get processing status of a video - */ - getVideoStatus: async (videoId: string): Promise => { - const response = await axiosClient.get(API_ROUTES.VIDEOS.STATUS(videoId)); - return response.data; - }, - /** * Get analysis results for a video */ diff --git a/src/types/index.ts b/src/types/index.ts index dd5de8d..d53e76b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,7 +2,6 @@ export * from './common'; export * from './project'; export * from './package'; export * from './chainage'; -export * from './video'; export * from './detection'; export * from './analysis'; export * from './session'; diff --git a/src/types/video.ts b/src/types/video.ts deleted file mode 100644 index 736e033..0000000 --- a/src/types/video.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Video related types - */ -export interface Video { - id: string; - filename: string; - detection_type: - | 'pothole-detection' - | 'sign-board-detection' - | 'pot-sign-detection'; - status: 'pending' | 'processing' | 'completed' | 'failed'; - unique_defected_sign_board?: number; - unique_pothole?: number; - unique_road_crack?: number; - unique_damaged_road_marking?: number; - unique_good_sign_board?: number; - unique_drain_issue?: number; - total_road_damage?: number; - total_detections?: number; - created_at: string; - updated_at: string; -} - -export interface VideoResultData { - videoId: string; - detectionType?: string; -} diff --git a/src/utils/routes.ts b/src/utils/routes.ts index 2a5f479..6ced5db 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -15,5 +15,5 @@ export const ROUTES = { ACCESS: '/access', ACCOUNT: '/account', UPLOAD: '/upload', - RESULTS: '/results', + TICKET: '/ticket', } as const;