From 182d4ac2930ef5daf7f2f654939f0ee790f5df1f Mon Sep 17 00:00:00 2001 From: "santasri.pachhal" Date: Wed, 17 Jun 2026 13:31:25 +0530 Subject: [PATCH] refactor: modularize road management modules --- src/app/(modules)/dashboard/page.tsx | 6 +- .../package/components/PackageColumns.tsx | 102 ++ .../package/components/PackageDialog.tsx | 170 ++++ .../package/components/PackageTable.tsx | 51 + .../package/hooks/usePackageFilters.ts | 20 + .../(modules)/package/hooks/usePackageForm.ts | 151 +++ .../package/hooks/usePackageQueries.ts | 50 + src/app/(modules)/package/page.tsx | 597 ++--------- .../(modules)/package/queries/packageKeys.ts | 7 + .../project/components/ProjectColumns.tsx | 82 ++ .../project/components/ProjectDialog.tsx | 180 ++++ .../project/components/ProjectTable.tsx | 51 + .../project/hooks/useProjectFilters.ts | 20 + .../(modules)/project/hooks/useProjectForm.ts | 146 +++ .../project/hooks/useProjectQueries.ts | 43 + src/app/(modules)/project/page.tsx | 569 ++--------- .../(modules)/project/queries/projectKeys.ts | 7 + .../segment/components/SegmentColumns.tsx | 150 +++ .../segment/components/SegmentDialog.tsx | 293 ++++++ .../segment/components/SegmentTable.tsx | 51 + .../segment/hooks/useSegmentFilters.ts | 20 + .../(modules)/segment/hooks/useSegmentForm.ts | 207 ++++ .../segment/hooks/useSegmentQueries.ts | 65 ++ src/app/(modules)/segment/page.tsx | 928 +++--------------- .../(modules)/segment/queries/segmentKeys.ts | 7 + .../video/detailed-summary-section.tsx | 7 +- src/constants/apiRoutes.ts | 56 ++ src/services/api/auth.service.ts | 13 +- src/services/api/chainage.service.ts | 13 +- src/services/api/client.service.ts | 13 +- src/services/api/detection.service.ts | 3 +- src/services/api/index.ts | 2 +- src/services/api/package.service.ts | 13 +- src/services/api/permission.service.ts | 7 +- src/services/api/plan.service.ts | 11 +- src/services/api/project-summary.service.ts | 52 + src/services/api/project.service.ts | 70 +- src/services/api/role.service.ts | 13 +- src/services/api/tenant.service.ts | 14 +- src/services/api/user.service.ts | 11 +- src/services/api/video.service.ts | 9 +- 41 files changed, 2386 insertions(+), 1894 deletions(-) create mode 100644 src/app/(modules)/package/components/PackageColumns.tsx create mode 100644 src/app/(modules)/package/components/PackageDialog.tsx create mode 100644 src/app/(modules)/package/components/PackageTable.tsx create mode 100644 src/app/(modules)/package/hooks/usePackageFilters.ts create mode 100644 src/app/(modules)/package/hooks/usePackageForm.ts create mode 100644 src/app/(modules)/package/hooks/usePackageQueries.ts create mode 100644 src/app/(modules)/package/queries/packageKeys.ts create mode 100644 src/app/(modules)/project/components/ProjectColumns.tsx create mode 100644 src/app/(modules)/project/components/ProjectDialog.tsx create mode 100644 src/app/(modules)/project/components/ProjectTable.tsx create mode 100644 src/app/(modules)/project/hooks/useProjectFilters.ts create mode 100644 src/app/(modules)/project/hooks/useProjectForm.ts create mode 100644 src/app/(modules)/project/hooks/useProjectQueries.ts create mode 100644 src/app/(modules)/project/queries/projectKeys.ts create mode 100644 src/app/(modules)/segment/components/SegmentColumns.tsx create mode 100644 src/app/(modules)/segment/components/SegmentDialog.tsx create mode 100644 src/app/(modules)/segment/components/SegmentTable.tsx create mode 100644 src/app/(modules)/segment/hooks/useSegmentFilters.ts create mode 100644 src/app/(modules)/segment/hooks/useSegmentForm.ts create mode 100644 src/app/(modules)/segment/hooks/useSegmentQueries.ts create mode 100644 src/app/(modules)/segment/queries/segmentKeys.ts create mode 100644 src/constants/apiRoutes.ts create mode 100644 src/services/api/project-summary.service.ts diff --git a/src/app/(modules)/dashboard/page.tsx b/src/app/(modules)/dashboard/page.tsx index 1ebc262..a8b59ae 100644 --- a/src/app/(modules)/dashboard/page.tsx +++ b/src/app/(modules)/dashboard/page.tsx @@ -28,7 +28,7 @@ import { DetectionDonutChart } from '@/components/dashboard/detection-donut-char import { ChainageBarChart } from '@/components/dashboard/chainage-bar-chart'; import { DashboardMap } from '@/components/dashboard/dashboard-map'; import { PageHeader } from '@/components/page-header'; -import { projectService } from '@/services/api'; +import { projectService, projectSummaryService } from '@/services/api'; import { Project } from '@/types'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; @@ -282,7 +282,9 @@ export default function DashboardPage() { try { setIsLoading(true); - const summary: ProjectSummary = await projectService.getProjectSummary(selectedProjectId); + const summary = await projectSummaryService.getProjectSummary( + selectedProjectId, + ); setProjectSummary(summary); } catch (err) { console.error('Failed to load project summary:', err); diff --git a/src/app/(modules)/package/components/PackageColumns.tsx b/src/app/(modules)/package/components/PackageColumns.tsx new file mode 100644 index 0000000..c2d11f9 --- /dev/null +++ b/src/app/(modules)/package/components/PackageColumns.tsx @@ -0,0 +1,102 @@ +'use client'; + +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 type { Package, Project } from '@/types'; + +function EmptyValue() { + return -; +} + +function StateBadges({ value }: { value: string | null }) { + if (!value) { + return ; + } + + const states = value + .split(',') + .map((state) => state.trim()) + .filter(Boolean); + + if (!states.length) { + return ; + } + + const [firstState, ...remainingStates] = states; + + return ( +
+ {firstState} + {remainingStates.length > 0 ? ( + + + + + +
+

+ Other States +

+ {remainingStates.map((state) => ( + + {state} + + ))} +
+
+
+ ) : null} +
+ ); +} + +export function usePackageColumns(projects: Project[]) { + return useMemo[]>( + () => [ + { + accessorKey: 'name', + header: 'Package Name', + cell: ({ row }) =>
{row.original.name}
, + }, + { + accessorKey: 'project_id', + header: 'Project', + cell: ({ row }) => + 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); + return ; + }, + }, + { + accessorKey: 'region', + header: 'Region', + cell: ({ row }) => row.original.region || , + }, + { + accessorKey: 'chainage_start_km', + header: 'Start (km)', + cell: ({ row }) => row.original.chainage_start_km?.toFixed(2) ?? '0.00', + }, + { + accessorKey: 'chainage_end_km', + header: 'End (km)', + cell: ({ row }) => row.original.chainage_end_km?.toFixed(2) ?? '0.00', + }, + ], + [projects], + ); +} diff --git a/src/app/(modules)/package/components/PackageDialog.tsx b/src/app/(modules)/package/components/PackageDialog.tsx new file mode 100644 index 0000000..77d499b --- /dev/null +++ b/src/app/(modules)/package/components/PackageDialog.tsx @@ -0,0 +1,170 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form'; +import { Globe, Loader2, Package as PackageIcon } from 'lucide-react'; + +import { FormField } from '@/components/form'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + 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'; + +interface PackageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + packageId?: string; + projects: Project[]; + isProjectsLoading: boolean; + projectId: string; + onProjectChange: (projectId: string) => void; + register: UseFormRegister; + errors: FieldErrors; + touchedFields: UseFormReturn['formState']['touchedFields']; + onSubmit: ComponentProps<'form'>['onSubmit']; + canSubmit: boolean; + isSaving: boolean; +} + +export function PackageDialog({ + open, + onOpenChange, + packageId, + projects, + isProjectsLoading, + projectId, + onProjectChange, + register, + errors, + touchedFields, + onSubmit, + canSubmit, + isSaving, +}: PackageDialogProps) { + const projectErrorMessage = touchedFields.project_id ? errors.project_id?.message : undefined; + const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined; + const startErrorMessage = touchedFields.chainage_start_km + ? errors.chainage_start_km?.message + : undefined; + const endErrorMessage = touchedFields.chainage_end_km + ? errors.chainage_end_km?.message + : undefined; + + return ( + + event.preventDefault()}> + + +
+ +
+ {packageId ? 'Edit Package Details' : 'Create New Package'} +
+ + {packageId + ? 'Update the technical specifications for your road infrastructure package.' + : 'Select a project and provide the essential data to establish a new package.'} + +
+ +
+ {!packageId ? ( + + + + + ) : null} + +
+ + + + + + + Region + + } + > + + + + + + + + + + +
+ + + + + +
+
+
+ ); +} diff --git a/src/app/(modules)/package/components/PackageTable.tsx b/src/app/(modules)/package/components/PackageTable.tsx new file mode 100644 index 0000000..06af78c --- /dev/null +++ b/src/app/(modules)/package/components/PackageTable.tsx @@ -0,0 +1,51 @@ +'use client'; + +import type { ColumnDef } from '@tanstack/react-table'; + +import { DataTable } from '@/components/data-table'; +import type { Package } from '@/types'; + +interface PackageTableProps { + columns: ColumnDef[]; + packages: Package[]; + isLoading: boolean; + skip: number; + limit: number; + total: number; + onPageChange: (skip: number) => void; + onLimitChange: (limit: number) => void; + onEdit: (pkg: Package) => void; + onDelete: (pkg: Package) => void; +} + +export function PackageTable({ + columns, + packages, + isLoading, + skip, + limit, + total, + onPageChange, + onLimitChange, + onEdit, + onDelete, +}: PackageTableProps) { + return ( + + ); +} diff --git a/src/app/(modules)/package/hooks/usePackageFilters.ts b/src/app/(modules)/package/hooks/usePackageFilters.ts new file mode 100644 index 0000000..1cdaf0b --- /dev/null +++ b/src/app/(modules)/package/hooks/usePackageFilters.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +export function usePackageFilters() { + const [skip, setSkip] = useState(0); + const [limit, setLimitValue] = useState(10); + + const setLimit = useCallback((value: number) => { + setLimitValue(value); + setSkip(0); + }, []); + + return { + skip, + setSkip, + limit, + setLimit, + }; +} diff --git a/src/app/(modules)/package/hooks/usePackageForm.ts b/src/app/(modules)/package/hooks/usePackageForm.ts new file mode 100644 index 0000000..269582c --- /dev/null +++ b/src/app/(modules)/package/hooks/usePackageForm.ts @@ -0,0 +1,151 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { packageService } from '@/services/api'; +import type { Package, PackageCreate, PackageUpdate } from '@/types'; + +import { packageKeys } from '../queries/packageKeys'; + +const optionalNumber = z.string().refine( + (value) => { + if (!value.trim()) { + return true; + } + return Number.isFinite(Number(value)); + }, + { message: 'Enter a valid number' }, +); + +const packageFormSchema = z.object({ + id: z.string().optional(), + project_id: z.string().trim().min(1, 'Project is required'), + name: z.string().trim().min(1, 'Package name is required'), + region: z.string().optional(), + chainage_start_km: optionalNumber, + chainage_end_km: optionalNumber, +}); + +export type PackageFormValues = z.infer; + +const defaultValues: PackageFormValues = { + project_id: '', + name: '', + region: '', + chainage_start_km: '', + chainage_end_km: '', +}; + +function trimOptional(value?: string) { + return value?.trim() || null; +} + +function toNumberOrZero(value: string) { + return value.trim() ? Number(value) : 0; +} + +function toPackagePayload(values: PackageFormValues): PackageCreate | PackageUpdate { + return { + project_id: values.project_id, + name: values.name.trim(), + region: trimOptional(values.region), + chainage_start_km: toNumberOrZero(values.chainage_start_km), + chainage_end_km: toNumberOrZero(values.chainage_end_km), + }; +} + +export function usePackageForm({ onSaved }: { onSaved: () => void }) { + const queryClient = useQueryClient(); + const { + register, + handleSubmit, + reset, + setValue, + control, + formState: { errors, isSubmitting, touchedFields }, + } = useForm({ + defaultValues, + mode: 'onTouched', + reValidateMode: 'onChange', + resolver: zodResolver(packageFormSchema), + }); + + 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 saveMutation = useMutation({ + mutationFn: (values: PackageFormValues) => { + const payload = toPackagePayload(values); + return values.id + ? packageService.updatePackage(values.id, payload) + : packageService.createPackage(payload as PackageCreate); + }, + onSuccess: (_data, values) => { + toast.success(values.id ? 'Package updated' : 'Package created'); + reset(defaultValues); + onSaved(); + queryClient.invalidateQueries({ queryKey: packageKeys.all }); + }, + onError: (_error, values) => { + toast.error(values.id ? 'Failed to update package' : 'Failed to create package'); + }, + }); + + const onSubmit = handleSubmit( + (values) => saveMutation.mutate(values), + (formErrors) => { + if (formErrors.project_id?.message) { + toast.error(formErrors.project_id.message); + return; + } + if (formErrors.name?.message) { + toast.error(formErrors.name.message); + } + }, + ); + + const openCreate = useCallback(() => { + reset(defaultValues); + }, [reset]); + + const openEdit = useCallback( + (pkg: Package) => { + reset({ + id: pkg.id, + project_id: pkg.project_id, + name: pkg.name || '', + region: pkg.region || '', + chainage_start_km: pkg.chainage_start_km?.toString() || '', + chainage_end_km: pkg.chainage_end_km?.toString() || '', + }); + }, + [reset], + ); + + const setProjectId = useCallback( + (value: string) => setValue('project_id', value, { shouldDirty: true, shouldValidate: true }), + [setValue], + ); + + return { + register, + onSubmit, + openCreate, + openEdit, + resetForm: () => reset(defaultValues), + packageId, + projectId, + setProjectId, + errors, + touchedFields, + canSubmit, + isSaving: isSubmitting || saveMutation.isPending, + }; +} diff --git a/src/app/(modules)/package/hooks/usePackageQueries.ts b/src/app/(modules)/package/hooks/usePackageQueries.ts new file mode 100644 index 0000000..144174e --- /dev/null +++ b/src/app/(modules)/package/hooks/usePackageQueries.ts @@ -0,0 +1,50 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { toast } from 'sonner'; + +import { packageService, projectService } from '@/services/api'; +import type { Package, PaginationParams } from '@/types'; + +import { packageKeys } from '../queries/packageKeys'; + +interface UsePackagesQueryParams { + skip: number; + limit: number; +} + +export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) { + const listParams = useMemo(() => ({ skip, limit }), [limit, skip]); + + return useQuery({ + queryKey: packageKeys.list(listParams), + queryFn: () => packageService.getPackages(listParams), + }); +} + +export function useProjectOptionsQuery() { + return useQuery({ + queryKey: ['projects', 'options'], + queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }), + }); +} + +export function useDeletePackageMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (pkg: Package) => packageService.deletePackage(pkg.id), + onSuccess: (_data, pkg) => { + toast.success('Package deleted', { + description: `${pkg.name} has been removed from the system.`, + }); + queryClient.invalidateQueries({ queryKey: packageKeys.all }); + }, + onError: () => { + toast.error('Deletion failed', { + description: 'The package could not be removed. Please try again.', + }); + }, + }); +} diff --git a/src/app/(modules)/package/page.tsx b/src/app/(modules)/package/page.tsx index 1168e0c..0370ab5 100644 --- a/src/app/(modules)/package/page.tsx +++ b/src/app/(modules)/package/page.tsx @@ -1,513 +1,132 @@ 'use client'; -import { useState, useEffect } from 'react'; -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'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Loader2, CheckCircle2, Package, FolderKanban, Globe } from 'lucide-react'; -import { DataTable } from '@/components/data-table'; +import { useCallback, useState } from 'react'; +import { Package as PackageIcon, Plus } from 'lucide-react'; + import { PageHeader } from '@/components/page-header'; -import { projectService, packageService } from '@/services/api'; -import { Project, Package as PackageType, PackageCreate, PackageUpdate } from '@/types'; -import { ColumnDef } from '@tanstack/react-table'; -import { toast } from 'sonner'; -import { Badge } from '@/components/ui/badge'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { PoweredBy } from '@/components/powered-by'; +import { Button } from '@/components/ui/button'; +import type { Package } from '@/types'; + +import { PackageDialog } from './components/PackageDialog'; +import { PackageTable } from './components/PackageTable'; +import { usePackageColumns } from './components/PackageColumns'; +import { usePackageFilters } from './hooks/usePackageFilters'; +import { usePackageForm } from './hooks/usePackageForm'; +import { + useDeletePackageMutation, + usePackagesQuery, + useProjectOptionsQuery, +} from './hooks/usePackageQueries'; export default function PackagePage() { - const [packages, setPackages] = useState([]); - const [totalItems, setTotalItems] = useState(0); - const [projects, setProjects] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isModalOpen, setIsModalOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - const [loadingProjects, setLoadingProjects] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const { skip, setSkip, limit, setLimit } = usePackageFilters(); + const packagesQuery = usePackagesQuery({ skip, limit }); + const projectsQuery = useProjectOptionsQuery(); + const deletePackageMutation = useDeletePackageMutation(); + const packageForm = usePackageForm({ + onSaved: () => setIsDialogOpen(false), + }); + const { + register, + onSubmit, + openCreate: prepareCreatePackage, + openEdit: prepareEditPackage, + resetForm, + packageId, + projectId, + setProjectId, + errors, + touchedFields, + canSubmit, + isSaving, + } = packageForm; - // Pagination state - const [skip, setSkip] = useState(0); - const [limit, setLimit] = useState(10); + const openCreate = useCallback(() => { + prepareCreatePackage(); + setIsDialogOpen(true); + }, [prepareCreatePackage]); - // Editing state - const [isEditing, setIsEditing] = useState(false); - const [currentPackage, setCurrentPackage] = useState(null); + const openEdit = useCallback( + (pkg: Package) => { + prepareEditPackage(pkg); + setIsDialogOpen(true); + }, + [prepareEditPackage], + ); - // Form fields - const [selectedProjectId, setSelectedProjectId] = useState(''); - const [name, setName] = useState(''); - const [region, setRegion] = useState(''); - const [chainageStartKm, setChainageStartKm] = useState(''); - const [chainageEndKm, setChainageEndKm] = useState(''); - - // Load packages and projects - const loadPackages = async (currentSkip = skip, currentLimit = limit) => { - try { - setIsLoading(true); - setError(null); - const data = await packageService.getPackages({ skip: currentSkip, limit: currentLimit }); - setPackages(data.items); - setTotalItems(data.totalItems); - } catch (err) { - setError('Failed to load packages. Please check if the backend is running.'); - } finally { - // Add a small delay for animation stability - setTimeout(() => { - setIsLoading(false); - }, 800); - } - }; - - const loadProjects = async () => { - try { - setLoadingProjects(true); - const data = await projectService.getProjects({ skip: 0, limit: 1000 }); // Load all projects for selector - setProjects(data.items); - } catch (err) { - setError('Failed to load projects.'); - } finally { - setLoadingProjects(false); - } - }; - - useEffect(() => { - loadPackages(skip, limit); - loadProjects(); - }, [skip, limit]); - - const resetForm = () => { - setSelectedProjectId(''); - setName(''); - setRegion(''); - setChainageStartKm(''); - setChainageEndKm(''); - setError(null); - setIsEditing(false); - setCurrentPackage(null); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!selectedProjectId) { - setError('Please select a project first'); - return; - } - if (!name.trim()) { - setError('Package name is required'); - return; - } - - setIsSubmitting(true); - setError(null); - - try { - if (isEditing && currentPackage) { - const data: PackageUpdate = { - name: name.trim(), - region: region.trim() || null, - chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : 0, - chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : 0, - }; - await packageService.updatePackage(currentPackage.id, data); - toast.success('Package Updated', { - description: `${name} has been updated successfully at ${new Date().toLocaleTimeString()}`, - }); - } else { - const data: PackageCreate = { - project_id: selectedProjectId, - name: name.trim(), - region: region.trim() || null, - chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : 0, - chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : 0, - }; - await packageService.createPackage(data); - toast.success('Package Created', { - description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`, - }); + const handleDialogOpenChange = useCallback( + (open: boolean) => { + setIsDialogOpen(open); + if (!open) { + resetForm(); } - - // Refresh packages list - await loadPackages(); - - // Close modal and reset form immediately - setIsModalOpen(false); - resetForm(); - } catch (err) { - const message = - err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} package`; - setError(message); - toast.error('Operation Failed', { - description: message, - }); - } finally { - setIsSubmitting(false); - } - }; - - const handleEdit = (pkg: PackageType) => { - setIsEditing(true); - setCurrentPackage(pkg); - setSelectedProjectId(pkg.project_id); - setName(pkg.name || ''); - setRegion(pkg.region || ''); - setChainageStartKm(pkg.chainage_start_km?.toString() || '0'); - setChainageEndKm(pkg.chainage_end_km?.toString() || '0'); - setIsModalOpen(true); - }; - - const handleDelete = async (pkg: PackageType) => { - if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) return; - - try { - setIsLoading(true); - await packageService.deletePackage(pkg.id); - toast.success('Package Deleted', { - description: `${pkg.name} has been removed from the system.`, - }); - await loadPackages(); - } catch (err) { - setError('Failed to delete package'); - toast.error('Deletion Failed', { - description: 'The package could not be removed. Please try again.', - }); - } finally { - setIsLoading(false); - } - }; - - const getProjectName = (projectId: string) => { - return projects.find((p) => p.id === projectId)?.name || projectId; - }; - - const columns: ColumnDef[] = [ - { - accessorKey: 'name', - header: 'Package Name', - cell: ({ row }) => ( -
{row.original.name}
- ), }, - { - accessorKey: 'project_id', - header: 'Project', - cell: ({ row }) => getProjectName(row.original.project_id), - }, - { - accessorKey: 'project_state', - header: 'Project State', - cell: ({ row }) => { - const pkg = row.original; - const project = projects.find((p) => p.id === pkg.project_id); - if (!project?.state) return ; + [resetForm], + ); - const states = project.state - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - if (states.length === 0) return ; + const deletePackage = useCallback( + (pkg: Package) => { + if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) { + return; + } + deletePackageMutation.mutate(pkg); + }, + [deletePackageMutation], + ); - const firstState = states[0]; - const remainingStates = states.slice(1); - - return ( -
- {firstState} - - {remainingStates.length > 0 && ( - - - - - -
-

- Other States -

- {remainingStates.map((item, idx) => ( - - {item} - - ))} -
-
-
- )} -
- ); - }, - }, - { - accessorKey: 'region', - header: 'Region', - }, - { - accessorKey: 'chainage_start_km', - header: 'Start (km)', - cell: ({ row }) => row.original.chainage_start_km?.toFixed(2) ?? '0.00', - }, - { - accessorKey: 'chainage_end_km', - header: 'End (km)', - cell: ({ row }) => row.original.chainage_end_km?.toFixed(2) ?? '0.00', - }, - ]; + const projects = projectsQuery.data?.items ?? []; + const packages = packagesQuery.data?.items ?? []; + const total = packagesQuery.data?.totalItems ?? 0; + const columns = usePackageColumns(projects); return ( <> -
- {/* Refined Header */} -
- { - setIsEditing(false); - setIsModalOpen(true); - }} - > - - Add New Package - - } - /> -
+
+ + + Add New Package + + } + /> - {/* Data Table */} -
- { - setLimit(newLimit); - setSkip(0); // Reset skip when limit changes - }, - }} - /> -
+
- {/* Modal Dialog */} - { - if (!open) { - setIsModalOpen(false); - resetForm(); - } else { - setIsModalOpen(true); - } - }} - > - e.preventDefault()}> - - -
- -
- {isEditing ? 'Edit Package Details' : 'Create New Package'} -
- - {isEditing - ? 'Update the technical specifications for your road infrastructure package.' - : 'Select a project and provide the essential data to establish a new package.'} - -
- -
- {/* Step 1: Select Project */} - {!isEditing && ( -
-
-
- 01 -
-

- Select Parent Project -

-
- -
- )} - - {/* Step 2: Package Info */} -
- {!isEditing && ( -
-
- 02 -
-

- Package Information -

-
- )} - -
-
- - setName(e.target.value)} - placeholder="e.g. Package 01" - className="h-11 bg-muted/20 border-border/60" - required - /> -
- -
- - setRegion(e.target.value)} - placeholder="e.g. North Zone" - className="h-11 bg-muted/20 border-border/60" - /> -
- -
- - setChainageStartKm(e.target.value)} - placeholder="0.00" - className="h-11 bg-muted/20 border-border/60" - /> -
- -
- - setChainageEndKm(e.target.value)} - placeholder="0.00" - className="h-11 bg-muted/20 border-border/60" - /> -
-
-
- - {/* Submit Button */} -
- - -
-
-
-
+ ); } diff --git a/src/app/(modules)/package/queries/packageKeys.ts b/src/app/(modules)/package/queries/packageKeys.ts new file mode 100644 index 0000000..9a87c52 --- /dev/null +++ b/src/app/(modules)/package/queries/packageKeys.ts @@ -0,0 +1,7 @@ +import type { PaginationParams } from '@/types'; + +export const packageKeys = { + all: ['packages'] as const, + lists: () => [...packageKeys.all, 'list'] as const, + list: (params: PaginationParams) => [...packageKeys.lists(), params] as const, +}; diff --git a/src/app/(modules)/project/components/ProjectColumns.tsx b/src/app/(modules)/project/components/ProjectColumns.tsx new file mode 100644 index 0000000..286fcf1 --- /dev/null +++ b/src/app/(modules)/project/components/ProjectColumns.tsx @@ -0,0 +1,82 @@ +'use client'; + +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 type { Project } from '@/types'; + +function EmptyValue() { + return -; +} + +function StateBadges({ value }: { value: string | null }) { + if (!value) { + return ; + } + + const states = value + .split(',') + .map((state) => state.trim()) + .filter(Boolean); + + if (!states.length) { + return ; + } + + const [firstState, ...remainingStates] = states; + + return ( +
+ {firstState} + {remainingStates.length > 0 ? ( + + + + + +
+

+ Other States +

+ {remainingStates.map((state) => ( + + {state} + + ))} +
+
+
+ ) : null} +
+ ); +} + +export function useProjectColumns() { + return useMemo[]>( + () => [ + { + accessorKey: 'name', + header: 'Project Name', + cell: ({ row }) =>
{row.original.name}
, + }, + { + accessorKey: 'state', + header: 'State', + cell: ({ row }) => , + }, + { + accessorKey: 'corridor_name', + header: 'Corridor', + cell: ({ row }) => row.original.corridor_name || , + }, + ], + [], + ); +} diff --git a/src/app/(modules)/project/components/ProjectDialog.tsx b/src/app/(modules)/project/components/ProjectDialog.tsx new file mode 100644 index 0000000..1f68038 --- /dev/null +++ b/src/app/(modules)/project/components/ProjectDialog.tsx @@ -0,0 +1,180 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form'; +import { Layers, Loader2, MapPin, Route } from 'lucide-react'; + +import { FormField } from '@/components/form'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; + +import type { ProjectFormValues } from '../hooks/useProjectForm'; + +interface ProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId?: string; + register: UseFormRegister; + errors: FieldErrors; + touchedFields: UseFormReturn['formState']['touchedFields']; + onSubmit: ComponentProps<'form'>['onSubmit']; + canSubmit: boolean; + isSaving: boolean; +} + +export function ProjectDialog({ + open, + onOpenChange, + projectId, + register, + errors, + touchedFields, + onSubmit, + canSubmit, + isSaving, +}: ProjectDialogProps) { + const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined; + const startLatErrorMessage = touchedFields.start_lat ? errors.start_lat?.message : undefined; + const startLngErrorMessage = touchedFields.start_lng ? errors.start_lng?.message : undefined; + const endLatErrorMessage = touchedFields.end_lat ? errors.end_lat?.message : undefined; + const endLngErrorMessage = touchedFields.end_lng ? errors.end_lng?.message : undefined; + + return ( + + event.preventDefault()}> + + +
+ +
+ {projectId ? 'Edit Project Details' : 'Create New Project'} +
+ + {projectId + ? 'Update the technical specifications for your road infrastructure project.' + : 'Provide the essential road data to establish a new analysis project.'} + +
+ +
+ + + + +
+ + + + + + Corridor Name + + } + > + + +
+ +
+
+

+ + START POINT +

+
+ + + + + + +
+
+ +
+

+ + END POINT +

+
+ + + + + + +
+
+
+ + + + + +
+
+
+ ); +} diff --git a/src/app/(modules)/project/components/ProjectTable.tsx b/src/app/(modules)/project/components/ProjectTable.tsx new file mode 100644 index 0000000..4a9b6e3 --- /dev/null +++ b/src/app/(modules)/project/components/ProjectTable.tsx @@ -0,0 +1,51 @@ +'use client'; + +import type { ColumnDef } from '@tanstack/react-table'; + +import { DataTable } from '@/components/data-table'; +import type { Project } from '@/types'; + +interface ProjectTableProps { + columns: ColumnDef[]; + projects: Project[]; + isLoading: boolean; + skip: number; + limit: number; + total: number; + onPageChange: (skip: number) => void; + onLimitChange: (limit: number) => void; + onEdit: (project: Project) => void; + onDelete: (project: Project) => void; +} + +export function ProjectTable({ + columns, + projects, + isLoading, + skip, + limit, + total, + onPageChange, + onLimitChange, + onEdit, + onDelete, +}: ProjectTableProps) { + return ( + + ); +} diff --git a/src/app/(modules)/project/hooks/useProjectFilters.ts b/src/app/(modules)/project/hooks/useProjectFilters.ts new file mode 100644 index 0000000..9f1f4f9 --- /dev/null +++ b/src/app/(modules)/project/hooks/useProjectFilters.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +export function useProjectFilters() { + const [skip, setSkip] = useState(0); + const [limit, setLimitValue] = useState(10); + + const setLimit = useCallback((value: number) => { + setLimitValue(value); + setSkip(0); + }, []); + + return { + skip, + setSkip, + limit, + setLimit, + }; +} diff --git a/src/app/(modules)/project/hooks/useProjectForm.ts b/src/app/(modules)/project/hooks/useProjectForm.ts new file mode 100644 index 0000000..234832d --- /dev/null +++ b/src/app/(modules)/project/hooks/useProjectForm.ts @@ -0,0 +1,146 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { projectService } from '@/services/api'; +import type { Project, ProjectCreate, ProjectUpdate } from '@/types'; + +import { projectKeys } from '../queries/projectKeys'; + +const optionalCoordinate = z.string().refine( + (value) => { + if (!value.trim()) { + return true; + } + return Number.isFinite(Number(value)); + }, + { message: 'Enter a valid number' }, +); + +const projectFormSchema = z.object({ + id: z.string().optional(), + name: z.string().trim().min(1, 'Project name is required'), + state: z.string().optional(), + corridor_name: z.string().optional(), + start_lat: optionalCoordinate, + start_lng: optionalCoordinate, + end_lat: optionalCoordinate, + end_lng: optionalCoordinate, +}); + +export type ProjectFormValues = z.infer; + +const defaultValues: ProjectFormValues = { + name: '', + state: '', + corridor_name: '', + start_lat: '', + start_lng: '', + end_lat: '', + end_lng: '', +}; + +function trimOptional(value?: string) { + return value?.trim() || null; +} + +function toNullableNumber(value: string) { + return value.trim() ? Number(value) : null; +} + +function toProjectPayload(values: ProjectFormValues): ProjectCreate | ProjectUpdate { + return { + name: values.name.trim(), + state: trimOptional(values.state), + corridor_name: trimOptional(values.corridor_name), + start_lat: toNullableNumber(values.start_lat), + start_lng: toNullableNumber(values.start_lng), + end_lat: toNullableNumber(values.end_lat), + end_lng: toNullableNumber(values.end_lng), + }; +} + +export function useProjectForm({ onSaved }: { onSaved: () => void }) { + const queryClient = useQueryClient(); + const { + register, + handleSubmit, + reset, + control, + formState: { errors, isSubmitting, touchedFields }, + } = useForm({ + defaultValues, + mode: 'onTouched', + reValidateMode: 'onChange', + resolver: zodResolver(projectFormSchema), + }); + + const projectId = useWatch({ control, name: 'id' }); + const projectName = useWatch({ control, name: 'name' }) || ''; + const canSubmit = projectName.trim().length > 0; + + const saveMutation = useMutation({ + mutationFn: (values: ProjectFormValues) => { + const payload = toProjectPayload(values); + return values.id + ? projectService.updateProject(values.id, payload) + : projectService.createProject(payload as ProjectCreate); + }, + onSuccess: (_data, values) => { + toast.success(values.id ? 'Project updated' : 'Project created'); + reset(defaultValues); + onSaved(); + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + onError: (_error, values) => { + toast.error(values.id ? 'Failed to update project' : 'Failed to create project'); + }, + }); + + const onSubmit = handleSubmit( + (values) => saveMutation.mutate(values), + (formErrors) => { + if (formErrors.name?.message) { + toast.error(formErrors.name.message); + } + }, + ); + + const openCreate = useCallback(() => { + reset(defaultValues); + }, [reset]); + + const openEdit = useCallback( + (project: Project) => { + reset({ + id: project.id, + name: project.name || '', + state: project.state || '', + corridor_name: project.corridor_name || '', + start_lat: project.start_lat?.toString() ?? '', + start_lng: project.start_lng?.toString() ?? '', + end_lat: project.end_lat?.toString() ?? '', + end_lng: project.end_lng?.toString() ?? '', + }); + }, + [reset], + ); + + return { + register, + onSubmit, + openCreate, + openEdit, + resetForm: () => reset(defaultValues), + projectId, + errors, + touchedFields, + canSubmit, + isSaving: isSubmitting || saveMutation.isPending, + }; +} diff --git a/src/app/(modules)/project/hooks/useProjectQueries.ts b/src/app/(modules)/project/hooks/useProjectQueries.ts new file mode 100644 index 0000000..aec3303 --- /dev/null +++ b/src/app/(modules)/project/hooks/useProjectQueries.ts @@ -0,0 +1,43 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { toast } from 'sonner'; + +import { projectService } from '@/services/api'; +import type { PaginationParams, Project } from '@/types'; + +import { projectKeys } from '../queries/projectKeys'; + +interface UseProjectsQueryParams { + skip: number; + limit: number; +} + +export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) { + const listParams = useMemo(() => ({ skip, limit }), [limit, skip]); + + return useQuery({ + queryKey: projectKeys.list(listParams), + queryFn: () => projectService.getProjects(listParams), + }); +} + +export function useDeleteProjectMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (project: Project) => projectService.deleteProject(project.id), + onSuccess: (_data, project) => { + toast.success('Project deleted', { + description: `${project.name} has been removed from the system.`, + }); + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + onError: () => { + toast.error('Deletion failed', { + description: 'The project could not be removed. Please try again or check your permissions.', + }); + }, + }); +} diff --git a/src/app/(modules)/project/page.tsx b/src/app/(modules)/project/page.tsx index f7146b3..ed07ff8 100644 --- a/src/app/(modules)/project/page.tsx +++ b/src/app/(modules)/project/page.tsx @@ -1,497 +1,120 @@ 'use client'; -import { useState, useEffect } from 'react'; -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'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Loader2, CheckCircle2, Layers, MapPin, Building2, Route, X } from 'lucide-react'; -import { DataTable } from '@/components/data-table'; +import { useCallback, useState } from 'react'; +import { Layers, Plus } from 'lucide-react'; + import { PageHeader } from '@/components/page-header'; -import { projectService } from '@/services/api'; -import { ProjectCreate, Project, ProjectUpdate } from '@/types'; -import { ColumnDef } from '@tanstack/react-table'; -import { toast } from 'sonner'; -import { Badge } from '@/components/ui/badge'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { PoweredBy } from '@/components/powered-by'; +import { Button } from '@/components/ui/button'; +import type { Project } from '@/types'; + +import { ProjectDialog } from './components/ProjectDialog'; +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'; export default function ProjectPage() { - const [projects, setProjects] = useState([]); - const [totalItems, setTotalItems] = useState(0); - const [isLoading, setIsLoading] = useState(true); - const [isModalOpen, setIsModalOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const { skip, setSkip, limit, setLimit } = useProjectFilters(); + const projectsQuery = useProjectsQuery({ skip, limit }); + const deleteProjectMutation = useDeleteProjectMutation(); + const projectForm = useProjectForm({ + onSaved: () => setIsDialogOpen(false), + }); + const { + register, + onSubmit, + openCreate: prepareCreateProject, + openEdit: prepareEditProject, + resetForm, + projectId, + errors, + touchedFields, + canSubmit, + isSaving, + } = projectForm; - // Pagination state - const [skip, setSkip] = useState(0); - const [limit, setLimit] = useState(10); + const openCreate = useCallback(() => { + prepareCreateProject(); + setIsDialogOpen(true); + }, [prepareCreateProject]); - // Editing state - const [isEditing, setIsEditing] = useState(false); - const [currentProject, setCurrentProject] = useState(null); + const openEdit = useCallback( + (project: Project) => { + prepareEditProject(project); + setIsDialogOpen(true); + }, + [prepareEditProject], + ); - // Form fields - const [name, setName] = useState(''); - const [state, setState] = useState(''); - const [corridorName, setCorridorName] = useState(''); - const [startLat, setStartLat] = useState(''); - const [startLng, setStartLng] = useState(''); - const [endLat, setEndLat] = useState(''); - const [endLng, setEndLng] = useState(''); - - // Load projects - const loadProjects = async (currentSkip = skip, currentLimit = limit) => { - try { - setIsLoading(true); - setError(null); - const data = await projectService.getProjects({ skip: currentSkip, limit: currentLimit }); - setProjects(data.items); - setTotalItems(data.totalItems); - } catch (err) { - setError('Failed to load projects. Please check if the backend is running.'); - } finally { - // Add a small delay for animation stability - setTimeout(() => { - setIsLoading(false); - }, 800); - } - }; - - useEffect(() => { - loadProjects(skip, limit); - }, [skip, limit]); - - const resetForm = () => { - setName(''); - setState(''); - setCorridorName(''); - setStartLat(''); - setStartLng(''); - setEndLat(''); - setEndLng(''); - setError(null); - setIsEditing(false); - setCurrentProject(null); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!name.trim()) { - setError('Project name is required'); - return; - } - - setIsSubmitting(true); - setError(null); - - try { - if (isEditing && currentProject) { - const data: ProjectUpdate = { - name: name.trim(), - state: state.trim() || null, - corridor_name: corridorName.trim() || null, - start_lat: startLat ? parseFloat(startLat) : null, - start_lng: startLng ? parseFloat(startLng) : null, - end_lat: endLat ? parseFloat(endLat) : null, - end_lng: endLng ? parseFloat(endLng) : null, - }; - await projectService.updateProject(currentProject.id, data); - toast.success('Project Updated', { - description: `${name} has been updated successfully at ${new Date().toLocaleTimeString()}`, - }); - } else { - const data: ProjectCreate = { - name: name.trim(), - state: state.trim() || null, - corridor_name: corridorName.trim() || null, - start_lat: startLat ? parseFloat(startLat) : null, - start_lng: startLng ? parseFloat(startLng) : null, - end_lat: endLat ? parseFloat(endLat) : null, - end_lng: endLng ? parseFloat(endLng) : null, - }; - await projectService.createProject(data); - toast.success('Project Created', { - description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`, - }); + const handleDialogOpenChange = useCallback( + (open: boolean) => { + setIsDialogOpen(open); + if (!open) { + resetForm(); } - - // Refresh projects list - await loadProjects(); - - // Close modal and reset form immediately - setIsModalOpen(false); - resetForm(); - } catch (err) { - const message = - err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} project`; - setError(message); - toast.error('Operation Failed', { - description: message, - }); - } finally { - setIsSubmitting(false); - } - }; - - const handleEdit = (project: Project) => { - setIsEditing(true); - setCurrentProject(project); - setName(project.name || ''); - setState(project.state || ''); - setCorridorName(project.corridor_name || ''); - setStartLat(project.start_lat?.toString() || ''); - setStartLng(project.start_lng?.toString() || ''); - setEndLat(project.end_lat?.toString() || ''); - setEndLng(project.end_lng?.toString() || ''); - setIsModalOpen(true); - }; - - const handleDelete = async (project: Project) => { - if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) return; - - try { - setIsLoading(true); - await projectService.deleteProject(project.id); - toast.success('Project Deleted', { - description: `${project.name} has been removed from the system.`, - }); - await loadProjects(); - } catch (err) { - setError('Failed to delete project'); - toast.error('Deletion Failed', { - description: - 'The project could not be removed. Please try again or check your permissions.', - }); - } finally { - setIsLoading(false); - } - }; - - const columns: ColumnDef[] = [ - { - accessorKey: 'name', - header: 'Project Name', - cell: ({ row }) =>
{row.original.name}
, }, - { - accessorKey: 'state', - header: 'State', - cell: ({ row }) => { - const project = row.original; - if (!project.state) return ; + [resetForm], + ); - const states = project.state - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - if (states.length === 0) return ; - - const firstState = states[0]; - const remainingStates = states.slice(1); - - return ( -
- {firstState} - - {remainingStates.length > 0 && ( - - - - - -
-

- Other States -

- {remainingStates.map((item, idx) => ( - - {item} - - ))} -
-
-
- )} -
- ); - }, + const deleteProject = useCallback( + (project: Project) => { + if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) { + return; + } + deleteProjectMutation.mutate(project); }, - { - accessorKey: 'corridor_name', - header: 'Corridor', - }, - ]; + [deleteProjectMutation], + ); + + const columns = useProjectColumns(); + const projects = projectsQuery.data?.items ?? []; + const total = projectsQuery.data?.totalItems ?? 0; return ( <> -
- {/* Refined Header */} -
- { - setIsEditing(false); - setIsModalOpen(true); - }} - > - - Add New Project - - } - /> -
+
+ + + Add Project + + } + /> - {/* Data Table */} -
- { - setLimit(newLimit); - setSkip(0); // Reset skip when limit changes - }, - }} - /> -
+
- {/* Modal Dialog */} - { - if (!open) { - setIsModalOpen(false); - resetForm(); - } else { - setIsModalOpen(true); - } - }} - > - e.preventDefault()}> - - -
- -
- {isEditing ? 'Edit Project Details' : 'Create New Project'} -
- - {isEditing - ? 'Update the technical specifications for your road infrastructure project.' - : 'Provide the essential road data to establish a new analysis project.'} - -
- -
- {/* Project Name */} -
- - setName(e.target.value)} - placeholder="Enter a descriptive project name..." - className="h-11 bg-muted/20 border-border/60" - required - /> -
- - {/* State & Corridor Row */} -
-
- - setState(e.target.value)} - placeholder="e.g. Maharashtra" - className="h-11 bg-muted/20 border-border/60" - /> -
-
- - setCorridorName(e.target.value)} - placeholder="e.g. Mumbai-Goa Highway" - className="h-11 bg-muted/20 border-border/60" - /> -
-
- - {/* GPS Coordinates Grid */} -
- {/* Start Point */} -
-

- START POINT -

-
-
- - setStartLat(e.target.value)} - placeholder="0.0000" - className="h-10 bg-muted/10 border-border/40 text-xs font-mono" - /> -
-
- - setStartLng(e.target.value)} - placeholder="0.0000" - className="h-10 bg-muted/10 border-border/40 text-xs font-mono" - /> -
-
-
- - {/* End Point */} -
-

- END POINT -

-
-
- - setEndLat(e.target.value)} - placeholder="0.0000" - className="h-10 bg-muted/10 border-border/40 text-xs font-mono" - /> -
-
- - setEndLng(e.target.value)} - placeholder="0.0000" - className="h-10 bg-muted/10 border-border/40 text-xs font-mono" - /> -
-
-
-
- - {/* Submit Button */} -
- - -
-
-
-
+ ); } diff --git a/src/app/(modules)/project/queries/projectKeys.ts b/src/app/(modules)/project/queries/projectKeys.ts new file mode 100644 index 0000000..1d3d374 --- /dev/null +++ b/src/app/(modules)/project/queries/projectKeys.ts @@ -0,0 +1,7 @@ +import type { PaginationParams } from '@/types'; + +export const projectKeys = { + all: ['projects'] as const, + lists: () => [...projectKeys.all, 'list'] as const, + list: (params: PaginationParams) => [...projectKeys.lists(), params] as const, +}; diff --git a/src/app/(modules)/segment/components/SegmentColumns.tsx b/src/app/(modules)/segment/components/SegmentColumns.tsx new file mode 100644 index 0000000..67572ca --- /dev/null +++ b/src/app/(modules)/segment/components/SegmentColumns.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useMemo } from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowDownCircle, ArrowUpCircle, MapPin, Milestone } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { Chainage, Package, Project } from '@/types'; + +function EmptyValue() { + return -; +} + +function StateBadges({ value }: { value: string | null }) { + if (!value) { + return ; + } + + const states = value + .split(',') + .map((state) => state.trim()) + .filter(Boolean); + + if (!states.length) { + return ; + } + + const [firstState, ...remainingStates] = states; + + return ( +
+ {firstState} + {remainingStates.length > 0 ? ( + + + + + +
+

+ Other States +

+ {remainingStates.map((state) => ( + + {state} + + ))} +
+
+
+ ) : null} +
+ ); +} + +export function useSegmentColumns(projects: Project[], packages: Package[]) { + return useMemo[]>( + () => [ + { + accessorKey: 'segment_name', + header: 'Segment Name', + cell: ({ row }) =>
{row.original.segment_name}
, + }, + { + accessorKey: 'package_id', + header: 'Package', + cell: ({ row }) => + packages.find((pkg) => pkg.id === row.original.package_id)?.name || + row.original.package_id, + }, + { + id: 'project', + header: 'Project', + cell: ({ row }) => { + const pkg = packages.find((item) => item.id === row.original.package_id); + const project = projects.find((item) => item.id === pkg?.project_id); + return project?.name || ; + }, + }, + { + id: 'project_state', + header: 'Project State', + cell: ({ row }) => { + const pkg = packages.find((item) => item.id === row.original.package_id); + const project = projects.find((item) => item.id === pkg?.project_id); + return ; + }, + }, + { + id: 'chainage', + header: 'Segment Range (km)', + cell: ({ row }) => { + const segment = row.original; + return ( +
+ + + {segment.chainage_start_km} - {segment.chainage_end_km} + + + {segment.direction === 'UP' ? ( + + ) : ( + + )} + {segment.direction} + +
+ ); + }, + }, + { + id: 'start_gps', + header: 'Start GPS', + cell: ({ row }) => ( + + + {row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)} + + ), + }, + { + id: 'end_gps', + header: 'End GPS', + cell: ({ row }) => ( + + + {row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)} + + ), + }, + ], + [packages, projects], + ); +} diff --git a/src/app/(modules)/segment/components/SegmentDialog.tsx b/src/app/(modules)/segment/components/SegmentDialog.tsx new file mode 100644 index 0000000..97c9008 --- /dev/null +++ b/src/app/(modules)/segment/components/SegmentDialog.tsx @@ -0,0 +1,293 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form'; +import { Loader2, MapPin, Milestone } from 'lucide-react'; + +import { FormField } from '@/components/form'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { Package, Project } from '@/types'; + +import type { SegmentFormValues } from '../hooks/useSegmentForm'; + +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; + onPackageChange: (packageId: string) => void; + direction: 'UP' | 'DOWN'; + onDirectionChange: (direction: 'UP' | 'DOWN') => void; + register: UseFormRegister; + errors: FieldErrors; + touchedFields: UseFormReturn['formState']['touchedFields']; + onSubmit: ComponentProps<'form'>['onSubmit']; + canSubmit: boolean; + isSaving: boolean; +} + +export function SegmentDialog({ + open, + onOpenChange, + segmentId, + projects, + packages, + isProjectsLoading, + isPackagesLoading, + projectId, + onProjectChange, + packageId, + onPackageChange, + direction, + onDirectionChange, + register, + errors, + touchedFields, + onSubmit, + canSubmit, + isSaving, +}: SegmentDialogProps) { + const getError = (field: keyof SegmentFormValues) => + touchedFields[field] ? errors[field]?.message : undefined; + + return ( + + event.preventDefault()} + > + + +
+ +
+ {segmentId ? 'Edit Segment' : 'Create Segment'} +
+ + {segmentId + ? 'Update the technical specifications for your road segment.' + : 'Select a project and package, then provide the segment data.'} + +
+ +
+ {!segmentId ? ( +
+ + + + + + + + + +
+ ) : null} + +
+ + + + + + + + + + + + + + + +
+ +
+
+

+ + START COORDINATES +

+
+ + + + + + +
+
+ +
+

+ + END COORDINATES +

+
+ + + + + + +
+
+
+ + + + + +
+
+
+ ); +} diff --git a/src/app/(modules)/segment/components/SegmentTable.tsx b/src/app/(modules)/segment/components/SegmentTable.tsx new file mode 100644 index 0000000..71af995 --- /dev/null +++ b/src/app/(modules)/segment/components/SegmentTable.tsx @@ -0,0 +1,51 @@ +'use client'; + +import type { ColumnDef } from '@tanstack/react-table'; + +import { DataTable } from '@/components/data-table'; +import type { Chainage } from '@/types'; + +interface SegmentTableProps { + columns: ColumnDef[]; + segments: Chainage[]; + isLoading: boolean; + skip: number; + limit: number; + total: number; + onPageChange: (skip: number) => void; + onLimitChange: (limit: number) => void; + onEdit: (segment: Chainage) => void; + onDelete: (segment: Chainage) => void; +} + +export function SegmentTable({ + columns, + segments, + isLoading, + skip, + limit, + total, + onPageChange, + onLimitChange, + onEdit, + onDelete, +}: SegmentTableProps) { + return ( + + ); +} diff --git a/src/app/(modules)/segment/hooks/useSegmentFilters.ts b/src/app/(modules)/segment/hooks/useSegmentFilters.ts new file mode 100644 index 0000000..e3d09c4 --- /dev/null +++ b/src/app/(modules)/segment/hooks/useSegmentFilters.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +export function useSegmentFilters() { + const [skip, setSkip] = useState(0); + const [limit, setLimitValue] = useState(10); + + const setLimit = useCallback((value: number) => { + setLimitValue(value); + setSkip(0); + }, []); + + return { + skip, + setSkip, + limit, + setLimit, + }; +} diff --git a/src/app/(modules)/segment/hooks/useSegmentForm.ts b/src/app/(modules)/segment/hooks/useSegmentForm.ts new file mode 100644 index 0000000..6542c6f --- /dev/null +++ b/src/app/(modules)/segment/hooks/useSegmentForm.ts @@ -0,0 +1,207 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { chainageService } from '@/services/api'; +import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types'; + +import { segmentKeys } from '../queries/segmentKeys'; + +const requiredNumber = (message: string) => + z + .string() + .trim() + .min(1, message) + .refine((value) => Number.isFinite(Number(value)), { message: 'Enter a valid number' }); + +const latitude = requiredNumber('Latitude is required').refine( + (value) => { + const numberValue = Number(value); + return numberValue >= -90 && numberValue <= 90; + }, + { message: 'Latitude must be between -90 and 90' }, +); + +const longitude = requiredNumber('Longitude is required').refine( + (value) => { + const numberValue = Number(value); + return numberValue >= -180 && numberValue <= 180; + }, + { message: 'Longitude must be between -180 and 180' }, +); + +const segmentFormSchema = z.object({ + id: z.string().optional(), + project_id: z.string().trim().min(1, 'Project is required'), + package_id: z.string().trim().min(1, 'Package is required'), + segment_name: z.string().trim().min(1, 'Segment name is required'), + chainage_start_km: requiredNumber('Start km is required'), + chainage_end_km: requiredNumber('End km is required'), + start_lat: latitude, + start_lng: longitude, + end_lat: latitude, + end_lng: longitude, + direction: z.enum(['UP', 'DOWN']), +}); + +export type SegmentFormValues = z.infer; + +const defaultValues: SegmentFormValues = { + project_id: '', + package_id: '', + segment_name: '', + chainage_start_km: '', + chainage_end_km: '', + start_lat: '', + start_lng: '', + end_lat: '', + end_lng: '', + direction: 'UP', +}; + +function toSegmentPayload(values: SegmentFormValues): ChainageCreate | ChainageUpdate { + return { + package_id: values.package_id, + segment_name: values.segment_name.trim(), + chainage_start_km: Number(values.chainage_start_km), + chainage_end_km: Number(values.chainage_end_km), + start_lat: Number(values.start_lat), + start_lng: Number(values.start_lng), + end_lat: Number(values.end_lat), + end_lng: Number(values.end_lng), + direction: values.direction, + }; +} + +export function useSegmentForm({ onSaved }: { onSaved: () => void }) { + const queryClient = useQueryClient(); + const { + register, + handleSubmit, + reset, + setValue, + control, + formState: { errors, isSubmitting, touchedFields }, + } = useForm({ + defaultValues, + mode: 'onTouched', + reValidateMode: 'onChange', + resolver: zodResolver(segmentFormSchema), + }); + + const segmentId = useWatch({ control, name: 'id' }); + const projectId = useWatch({ control, name: 'project_id' }) || ''; + 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 chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || ''; + const startLat = useWatch({ control, name: 'start_lat' }) || ''; + const startLng = useWatch({ control, name: 'start_lng' }) || ''; + const endLat = useWatch({ control, name: 'end_lat' }) || ''; + const endLng = useWatch({ control, name: 'end_lng' }) || ''; + const canSubmit = + projectId.trim().length > 0 && + packageId.trim().length > 0 && + segmentName.trim().length > 0 && + chainageStartKm.trim().length > 0 && + chainageEndKm.trim().length > 0 && + startLat.trim().length > 0 && + startLng.trim().length > 0 && + endLat.trim().length > 0 && + endLng.trim().length > 0; + + const saveMutation = useMutation({ + mutationFn: (values: SegmentFormValues) => { + const payload = toSegmentPayload(values); + return values.id + ? chainageService.updateChainage(values.id, payload) + : chainageService.createChainage(payload as ChainageCreate); + }, + onSuccess: (_data, values) => { + toast.success(values.id ? 'Segment updated' : 'Segment created'); + reset(defaultValues); + onSaved(); + queryClient.invalidateQueries({ queryKey: segmentKeys.all }); + }, + onError: (_error, values) => { + 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; + if (firstMessage) { + toast.error(String(firstMessage)); + } + }, + ); + + const openCreate = useCallback(() => { + reset(defaultValues); + }, [reset]); + + const openEdit = useCallback( + (segment: Chainage, projectIdForSegment: string) => { + reset({ + id: segment.id, + project_id: projectIdForSegment, + package_id: segment.package_id, + segment_name: segment.segment_name || '', + chainage_start_km: segment.chainage_start_km?.toString() || '', + chainage_end_km: segment.chainage_end_km?.toString() || '', + start_lat: segment.start_lat?.toString() || '', + start_lng: segment.start_lng?.toString() || '', + end_lat: segment.end_lat?.toString() || '', + end_lng: segment.end_lng?.toString() || '', + direction: segment.direction || 'UP', + }); + }, + [reset], + ); + + const setProjectId = useCallback( + (value: string) => { + 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 }), + [setValue], + ); + + const setDirection = useCallback( + (value: 'UP' | 'DOWN') => + setValue('direction', value, { shouldDirty: true, shouldValidate: true }), + [setValue], + ); + + return { + register, + onSubmit, + openCreate, + openEdit, + resetForm: () => reset(defaultValues), + segmentId, + projectId, + setProjectId, + packageId, + setPackageId, + direction, + setDirection, + errors, + touchedFields, + canSubmit, + isSaving: isSubmitting || saveMutation.isPending, + }; +} diff --git a/src/app/(modules)/segment/hooks/useSegmentQueries.ts b/src/app/(modules)/segment/hooks/useSegmentQueries.ts new file mode 100644 index 0000000..e637703 --- /dev/null +++ b/src/app/(modules)/segment/hooks/useSegmentQueries.ts @@ -0,0 +1,65 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { toast } from 'sonner'; + +import { chainageService, packageService, projectService } from '@/services/api'; +import type { Chainage, PaginationParams } from '@/types'; + +import { segmentKeys } from '../queries/segmentKeys'; + +interface UseSegmentsQueryParams { + skip: number; + limit: number; +} + +export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) { + const listParams = useMemo(() => ({ skip, limit }), [limit, skip]); + + return useQuery({ + queryKey: segmentKeys.list(listParams), + queryFn: () => chainageService.getChainages(listParams), + }); +} + +export function useProjectOptionsQuery() { + return useQuery({ + queryKey: ['projects', 'options'], + queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }), + }); +} + +export function useAllPackageOptionsQuery() { + return useQuery({ + queryKey: ['packages', 'options'], + queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }), + }); +} + +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(); + + return useMutation({ + mutationFn: (segment: Chainage) => chainageService.deleteChainage(segment.id), + onSuccess: (_data, segment) => { + toast.success('Segment deleted', { + description: `${segment.segment_name} has been removed from the system.`, + }); + queryClient.invalidateQueries({ queryKey: segmentKeys.all }); + }, + onError: () => { + toast.error('Deletion failed', { + description: 'The segment could not be removed. Please try again.', + }); + }, + }); +} diff --git a/src/app/(modules)/segment/page.tsx b/src/app/(modules)/segment/page.tsx index 1f9d8e5..8c00927 100644 --- a/src/app/(modules)/segment/page.tsx +++ b/src/app/(modules)/segment/page.tsx @@ -1,829 +1,149 @@ 'use client'; -import { useState, useEffect } from 'react'; -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'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { - Loader2, - CheckCircle2, - MapPin, - Navigation, - Milestone, - ArrowUpCircle, - ArrowDownCircle, -} from 'lucide-react'; -import { DataTable } from '@/components/data-table'; +import { useCallback, useState } from 'react'; +import { Milestone, Plus } from 'lucide-react'; + import { PageHeader } from '@/components/page-header'; import { PoweredBy } from '@/components/powered-by'; -import { projectService, packageService, chainageService } from '@/services/api'; -import { Project, Package as PackageType, Chainage, ChainageCreate, ChainageUpdate } from '@/types'; -import { ColumnDef } from '@tanstack/react-table'; -import { toast } from 'sonner'; -import { Badge } from '@/components/ui/badge'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import type { Chainage } from '@/types'; -export default function ChainagePage() { - const [chainages, setChainages] = useState([]); - const [totalItems, setTotalItems] = useState(0); - const [projects, setProjects] = useState([]); - const [packages, setPackages] = useState([]); - const [allPackages, setAllPackages] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isModalOpen, setIsModalOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - const [loadingProjects, setLoadingProjects] = useState(false); - const [loadingPackages, setLoadingPackages] = useState(false); +import { SegmentDialog } from './components/SegmentDialog'; +import { SegmentTable } from './components/SegmentTable'; +import { useSegmentColumns } from './components/SegmentColumns'; +import { useSegmentFilters } from './hooks/useSegmentFilters'; +import { useSegmentForm } from './hooks/useSegmentForm'; +import { + useAllPackageOptionsQuery, + useDeleteSegmentMutation, + usePackagesByProjectQuery, + useProjectOptionsQuery, + useSegmentsQuery, +} from './hooks/useSegmentQueries'; - // Pagination state - const [skip, setSkip] = useState(0); - const [limit, setLimit] = useState(10); +export default function SegmentPage() { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const { skip, setSkip, limit, setLimit } = useSegmentFilters(); + const segmentsQuery = useSegmentsQuery({ skip, limit }); + const projectsQuery = useProjectOptionsQuery(); + const allPackagesQuery = useAllPackageOptionsQuery(); + const deleteSegmentMutation = useDeleteSegmentMutation(); + const segmentForm = useSegmentForm({ + onSaved: () => setIsDialogOpen(false), + }); + const { + register, + onSubmit, + openCreate: prepareCreateSegment, + openEdit: prepareEditSegment, + resetForm, + segmentId, + projectId, + setProjectId, + packageId, + setPackageId, + direction, + setDirection, + errors, + touchedFields, + canSubmit, + isSaving, + } = segmentForm; + const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen); - // Editing state - const [isEditing, setIsEditing] = useState(false); - const [currentChainage, setCurrentChainage] = useState(null); + const openCreate = useCallback(() => { + prepareCreateSegment(); + setIsDialogOpen(true); + }, [prepareCreateSegment]); - // Form fields - const [selectedProjectId, setSelectedProjectId] = useState(''); - const [selectedPackageId, setSelectedPackageId] = useState(''); - const [segmentName, setSegmentName] = useState(''); - const [chainageStartKm, setChainageStartKm] = useState(''); - const [chainageEndKm, setChainageEndKm] = useState(''); - const [startLat, setStartLat] = useState(''); - const [startLng, setStartLng] = useState(''); - const [endLat, setEndLat] = useState(''); - const [endLng, setEndLng] = useState(''); - const [direction, setDirection] = useState<'UP' | 'DOWN'>('UP'); + const openEdit = useCallback( + (segment: Chainage) => { + const pkg = allPackagesQuery.data?.items.find((item) => item.id === segment.package_id); + prepareEditSegment(segment, pkg?.project_id || ''); + setIsDialogOpen(true); + }, + [allPackagesQuery.data?.items, prepareEditSegment], + ); - // Load chainages and projects - const loadChainages = async (currentSkip = skip, currentLimit = limit) => { - try { - setIsLoading(true); - setError(null); - const data = await chainageService.getChainages({ skip: currentSkip, limit: currentLimit }); - setChainages(data.items); - setTotalItems(data.totalItems); - } catch (err) { - setError('Failed to load segments. Please check if the backend is running.'); - } finally { - // Add a small delay for animation stability - setTimeout(() => { - setIsLoading(false); - }, 800); - } - }; - - const loadProjects = async () => { - try { - setLoadingProjects(true); - const data = await projectService.getProjects({ skip: 0, limit: 1000 }); - setProjects(data.items); - } catch (err) { - setError('Failed to load projects.'); - } finally { - setLoadingProjects(false); - } - }; - - const loadAllPackages = async () => { - try { - const data = await packageService.getPackages({ skip: 0, limit: 1000 }); - setAllPackages(data.items); - } catch (err) { - console.error('Failed to load all packages'); - } - }; - - useEffect(() => { - loadChainages(skip, limit); - loadProjects(); - loadAllPackages(); - }, [skip, limit]); - - // Load packages when project changes - useEffect(() => { - if (!selectedProjectId) { - setPackages([]); - if (!isEditing) setSelectedPackageId(''); - return; - } - - const loadPackagesForProject = async () => { - try { - setLoadingPackages(true); - if (!isEditing) setSelectedPackageId(''); - const data = await packageService.getPackagesByProject(selectedProjectId, { - skip: 0, - limit: 1000, - }); - setPackages(data.items); - } catch (err) { - setError('Failed to load packages for the selected project.'); - } finally { - setLoadingPackages(false); + const handleDialogOpenChange = useCallback( + (open: boolean) => { + setIsDialogOpen(open); + if (!open) { + resetForm(); } - }; - loadPackagesForProject(); - }, [selectedProjectId, isEditing]); + }, + [resetForm], + ); - const resetForm = () => { - setSelectedProjectId(''); - setSelectedPackageId(''); - setSegmentName(''); - setChainageStartKm(''); - setChainageEndKm(''); - setStartLat(''); - setStartLng(''); - setEndLat(''); - setEndLng(''); - setDirection('UP'); - setError(null); - setIsEditing(false); - setCurrentChainage(null); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!selectedPackageId && !isEditing) { - setError('Please select a project and package first'); - return; - } - if (!segmentName.trim()) { - setError('Segment name is required'); - return; - } - if (!startLat || !startLng || !endLat || !endLng) { - setError('All GPS coordinates are required for segments'); - return; - } - const parsedStartLat = parseFloat(startLat); - const parsedStartLng = parseFloat(startLng); - const parsedEndLat = parseFloat(endLat); - const parsedEndLng = parseFloat(endLng); - - if (parsedStartLat < -90 || parsedStartLat > 90 || parsedEndLat < -90 || parsedEndLat > 90) { - setError('Latitude values must be between -90 and 90'); - return; - } - if ( - parsedStartLng < -180 || - parsedStartLng > 180 || - parsedEndLng < -180 || - parsedEndLng > 180 - ) { - setError('Longitude values must be between -180 and 180'); - return; - } - if (!chainageStartKm || !chainageEndKm) { - setError('Segment start and end values are required'); - return; - } - - setIsSubmitting(true); - setError(null); - - try { - if (isEditing && currentChainage) { - const data: ChainageUpdate = { - segment_name: segmentName.trim(), - chainage_start_km: parseFloat(chainageStartKm), - chainage_end_km: parseFloat(chainageEndKm), - start_lat: parsedStartLat, - start_lng: parsedStartLng, - end_lat: parsedEndLat, - end_lng: parsedEndLng, - direction: direction as 'UP' | 'DOWN', - }; - await chainageService.updateChainage(currentChainage.id, data); - toast.success('Segment Updated', { - description: `${segmentName} has been updated successfully at ${new Date().toLocaleTimeString()}`, - }); - } else { - const data: ChainageCreate = { - package_id: selectedPackageId, - segment_name: segmentName.trim(), - chainage_start_km: parseFloat(chainageStartKm), - chainage_end_km: parseFloat(chainageEndKm), - start_lat: parsedStartLat, - start_lng: parsedStartLng, - end_lat: parsedEndLat, - end_lng: parsedEndLng, - direction: direction, - }; - await chainageService.createChainage(data); - toast.success('Segment Created', { - description: `${segmentName} has been established successfully at ${new Date().toLocaleTimeString()}`, - }); + const deleteSegment = useCallback( + (segment: Chainage) => { + if (!confirm(`Are you sure you want to delete segment "${segment.segment_name}"?`)) { + return; } - - // Refresh chainages list - await loadChainages(); - - // Close modal and reset form immediately - setIsModalOpen(false); - resetForm(); - } catch (err) { - const message = - err instanceof Error - ? err.message - : `Failed to ${isEditing ? 'update' : 'create'} segment`; - setError(message); - toast.error('Operation Failed', { - description: message, - }); - } finally { - setIsSubmitting(false); - } - }; - - const handleEdit = (chainage: Chainage) => { - setIsEditing(true); - setCurrentChainage(chainage); - - // Find project for this package - const pkg = allPackages.find((p) => p.id === chainage.package_id); - if (pkg) { - setSelectedProjectId(pkg.project_id); - setSelectedPackageId(chainage.package_id); - } - - setSegmentName(chainage.segment_name || ''); - setChainageStartKm(chainage.chainage_start_km?.toString() || ''); - setChainageEndKm(chainage.chainage_end_km?.toString() || ''); - setStartLat(chainage.start_lat.toString()); - setStartLng(chainage.start_lng.toString()); - setEndLat(chainage.end_lat.toString()); - setEndLng(chainage.end_lng.toString()); - setDirection(chainage.direction); - setIsModalOpen(true); - }; - - const handleDelete = async (chainage: Chainage) => { - if (!confirm(`Are you sure you want to delete segment "${chainage.segment_name}"?`)) return; - - try { - setIsLoading(true); - await chainageService.deleteChainage(chainage.id); - toast.success('Segment Deleted', { - description: `${chainage.segment_name} has been removed from the system.`, - }); - await loadChainages(); - } catch (err) { - setError('Failed to delete segment'); - toast.error('Deletion Failed', { - description: 'The segment could not be removed. Please try again.', - }); - } finally { - setIsLoading(false); - } - }; - - const getPackageName = (packageId: string) => { - return allPackages.find((p) => p.id === packageId)?.name || packageId; - }; - - const isFormComplete = - selectedPackageId && - segmentName.trim() && - startLat && - startLng && - endLat && - endLng && - chainageStartKm && - chainageEndKm; - - const columns: ColumnDef[] = [ - { - accessorKey: 'segment_name', - header: 'Segment Name', - cell: ({ row }) =>
{row.original.segment_name}
, + deleteSegmentMutation.mutate(segment); }, - { - accessorKey: 'package_id', - header: 'Package', - cell: ({ row }) => getPackageName(row.original.package_id), - }, - { - accessorKey: 'project', - header: 'Project', - cell: ({ row }) => { - const chainage = row.original; - const pkg = allPackages.find((p) => p.id === chainage.package_id); - const project = projects.find((p) => p.id === pkg?.project_id); - return project?.name || '—'; - }, - }, - { - id: 'project_state', - header: 'Project State', - cell: ({ row }) => { - const chainage = row.original; - const pkg = allPackages.find((p) => p.id === chainage.package_id); - const project = projects.find((p) => p.id === pkg?.project_id); - if (!project?.state) return ; + [deleteSegmentMutation], + ); - const states = project.state - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - if (states.length === 0) return ; - - const firstState = states[0]; - const remainingStates = states.slice(1); - - return ( -
- {firstState} - - {remainingStates.length > 0 && ( - - - - - -
-

- Other States -

- {remainingStates.map((item, idx) => ( - - {item} - - ))} -
-
-
- )} -
- ); - }, - }, - { - accessorKey: 'chainage', - header: 'Segment Range (km)', - cell: ({ row }) => { - const chainage = row.original; - return ( -
- - - {chainage.chainage_start_km} - {chainage.chainage_end_km} - - - {chainage.direction === 'UP' ? ( - - ) : ( - - )} - {chainage.direction} - -
- ); - }, - }, - { - accessorKey: 'start_gps', - header: 'Start GPS', - cell: ({ row }) => ( - - - {row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)} - - ), - }, - { - accessorKey: 'end_gps', - header: 'End GPS', - cell: ({ row }) => ( - - - {row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)} - - ), - }, - ]; + 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); return ( <> -
- {/* Refined Header */} -
- { - setIsEditing(false); - setIsModalOpen(true); - }} - > - - Add Segment - - } - /> -
+
+ + + Add Segment + + } + /> - {/* Data Table */} -
- { - setLimit(newLimit); - setSkip(0); // Reset skip when limit changes - }, - }} - /> -
+
- {/* Modal Dialog */} - { - if (!open) { - setIsModalOpen(false); - resetForm(); - } else { - setIsModalOpen(true); - } - }} - > - e.preventDefault()} - > - - -
- -
- {isEditing ? 'Edit Segment' : 'Create Segment'} -
- - {isEditing - ? 'Update the technical specifications for your road segment.' - : 'Select a project & package, then provide the essential segment data.'} - -
- -
- {!isEditing && ( -
- {/* Step 1: Select Project */} -
-
-
- 01 -
-

- Select Project -

-
- -
- - {/* Step 2: Select Package */} -
-
-
- 02 -
-

- Select Package -

-
- -
-
- )} - - {/* Step 3: Chainage Details */} -
- {!isEditing && ( -
-
- 03 -
-

- Segment Information -

-
- )} - - {/* Segment Name & Chainage Row */} -
-
- - setSegmentName(e.target.value)} - placeholder="e.g. Mumbai to Pune" - className="h-11 bg-muted/20 border-border/60" - required - /> -
-
-
- - setChainageStartKm(e.target.value)} - placeholder="0.0" - className="h-11 bg-muted/20 border-border/60 text-sm font-mono" - required - /> -
-
- - setChainageEndKm(e.target.value)} - placeholder="1.0" - className="h-11 bg-muted/20 border-border/60 text-sm font-mono" - required - /> -
-
-
- - {/* Direction Row */} -
- - -
- - {/* GPS Coordinates Grid */} -
-
-

- START COORDINATES - * -

-
-
- - setStartLat(e.target.value)} - placeholder="-90 to 90" - className="h-10 bg-background/70 border-border/40 text-xs font-mono" - required - /> -
-
- - setStartLng(e.target.value)} - placeholder="-180 to 180" - className="h-10 bg-background/70 border-border/40 text-xs font-mono" - required - /> -
-
-
- -
-

- END COORDINATES - * -

-
-
- - setEndLat(e.target.value)} - placeholder="-90 to 90" - className="h-10 bg-background/70 border-border/40 text-xs font-mono" - required - /> -
-
- - setEndLng(e.target.value)} - placeholder="-180 to 180" - className="h-10 bg-background/70 border-border/40 text-xs font-mono" - required - /> -
-
-
-
-
- - {/* Submit Button */} -
- - -
-
-
-
+ ); } diff --git a/src/app/(modules)/segment/queries/segmentKeys.ts b/src/app/(modules)/segment/queries/segmentKeys.ts new file mode 100644 index 0000000..65b4c19 --- /dev/null +++ b/src/app/(modules)/segment/queries/segmentKeys.ts @@ -0,0 +1,7 @@ +import type { PaginationParams } from '@/types'; + +export const segmentKeys = { + all: ['segments'] as const, + lists: () => [...segmentKeys.all, 'list'] as const, + list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const, +}; diff --git a/src/components/video/detailed-summary-section.tsx b/src/components/video/detailed-summary-section.tsx index 7628d18..24c5a92 100644 --- a/src/components/video/detailed-summary-section.tsx +++ b/src/components/video/detailed-summary-section.tsx @@ -8,7 +8,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ChainageSummaryData } from '@/types'; -import { projectService } from '@/services/api'; +import { projectSummaryService } from '@/services/api'; import { getDetectionModeConfig } from '@/constants/detectionModeConfig'; // Dynamically import MapModal with SSR disabled (Leaflet requires window object) @@ -37,7 +37,10 @@ const DetailedSummarySection = ({ const fetchSummary = async () => { setLoading(true); try { - const data = await projectService.getProjectSummaryByVideo(projectId, videoId); + const data = await projectSummaryService.getProjectSummaryByVideo( + projectId, + videoId, + ); setSummaryData(data); } catch (err) { console.error('Failed to fetch summary:', err); diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts new file mode 100644 index 0000000..b54d3d1 --- /dev/null +++ b/src/constants/apiRoutes.ts @@ -0,0 +1,56 @@ +export const API_ROUTES = { + AUTH: { + LOGIN: 'api/auth/login', + REFRESH: 'api/auth/refresh', + LOGOUT: 'api/auth/logout', + ME: 'api/auth/me', + }, + PERMISSIONS: { + MY_PERMISSIONS: 'api/permissions/my-permissions', + ORGANIZATION_TREE: 'api/permissions/organization-tree', + }, + ROLES: { + BASE: 'api/roles', + DETAIL: (id: number) => `api/roles/${id}`, + STATUS: (id: number) => `api/roles/${id}/status`, + }, + USERS: { + BASE: 'api/users', + STATUS: (id: number) => `api/users/${id}/status`, + }, + CLIENTS: { + BASE: 'api/clients', + DETAIL: (id: number) => `api/clients/${id}`, + STATUS: (id: number) => `api/clients/${id}/status`, + }, + PLANS: { + BASE: 'api/superadmin/plans', + DETAIL: (id: number) => `api/superadmin/plans/${id}`, + STATUS: (id: number) => `api/superadmin/plans/${id}/status`, + }, + TENANTS: { + BASE: 'api/superadmin/tenants', + DETAIL: (id: number) => `api/superadmin/tenants/${id}`, + }, + PROJECTS: { + BASE: 'biz/api/v1/projects', + DETAIL: (id: string) => `biz/api/v1/projects/${id}`, + }, + PACKAGES: { + BASE: 'biz/api/v1/packages', + DETAIL: (id: string) => `biz/api/v1/packages/${id}`, + }, + CHAINAGES: { + BASE: 'biz/api/v1/chainages', + DETAIL: (id: string) => `biz/api/v1/chainages/${id}`, + }, + DASHBOARD: { + OVERVIEW: 'biz/api/v1/dashboard/overview', + }, + VIDEOS: { + LIST: '/videos', + UPLOAD: '/upload', + STATUS: (id: string) => `/status/${id}`, + RESULTS: (id: string) => `/results/${id}`, + }, +} as const; diff --git a/src/services/api/auth.service.ts b/src/services/api/auth.service.ts index c8bff86..80cbaec 100644 --- a/src/services/api/auth.service.ts +++ b/src/services/api/auth.service.ts @@ -1,28 +1,31 @@ import type { AuthResponseData, LoginPayload, MeResponse, PermissionResponse } from '@/types'; +import { API_ROUTES } from '@/constants/apiRoutes'; import axiosClient, { axiosAuth } from '../axios/axios'; export const authService = { login: async (payload: LoginPayload): Promise => { - const response = await axiosAuth.post('api/auth/login', payload, { + const response = await axiosAuth.post(API_ROUTES.AUTH.LOGIN, payload, { withCredentials: true, }); return response.data; }, refresh: async (): Promise => { - const response = await axiosAuth.post('api/auth/refresh', {}, { + const response = await axiosAuth.post(API_ROUTES.AUTH.REFRESH, {}, { withCredentials: true, }); return response.data; }, logout: async (): Promise => { - await axiosAuth.post('api/auth/logout', {}, { withCredentials: true }); + await axiosAuth.post(API_ROUTES.AUTH.LOGOUT, {}, { withCredentials: true }); }, me: async (): Promise => { - const response = await axiosClient.get('api/auth/me'); + const response = await axiosClient.get(API_ROUTES.AUTH.ME); return response.data; }, permissions: async (): Promise => { - const response = await axiosClient.get('api/permissions/my-permissions'); + const response = await axiosClient.get( + API_ROUTES.PERMISSIONS.MY_PERMISSIONS, + ); return response.data; }, }; diff --git a/src/services/api/chainage.service.ts b/src/services/api/chainage.service.ts index 47d724c..f49b764 100644 --- a/src/services/api/chainage.service.ts +++ b/src/services/api/chainage.service.ts @@ -1,4 +1,5 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import { Chainage, ChainageCreate, @@ -7,8 +8,6 @@ import { PaginationParams, } from '@/types'; -const CHAINAGES_ENDPOINT = 'biz/api/v1/chainages'; - /** * Chainage Service */ @@ -19,7 +18,7 @@ export const chainageService = { getChainages: async (params?: PaginationParams): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(CHAINAGES_ENDPOINT, { + const response = await axiosClient.get>(API_ROUTES.CHAINAGES.BASE, { params: { skip, limit }, }); return response.data; @@ -34,7 +33,7 @@ export const chainageService = { ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(CHAINAGES_ENDPOINT, { + const response = await axiosClient.get>(API_ROUTES.CHAINAGES.BASE, { params: { package_id: packageId, skip, limit }, }); return response.data; @@ -44,7 +43,7 @@ export const chainageService = { * Create a new chainage */ createChainage: async (data: ChainageCreate): Promise => { - const response = await axiosClient.post(CHAINAGES_ENDPOINT, data); + const response = await axiosClient.post(API_ROUTES.CHAINAGES.BASE, data); return response.data; }, @@ -52,7 +51,7 @@ export const chainageService = { * Update an existing chainage */ updateChainage: async (chainageId: string, data: ChainageUpdate): Promise => { - const response = await axiosClient.put(`${CHAINAGES_ENDPOINT}/${chainageId}`, data); + const response = await axiosClient.put(API_ROUTES.CHAINAGES.DETAIL(chainageId), data); return response.data; }, @@ -61,7 +60,7 @@ export const chainageService = { */ deleteChainage: async (chainageId: string): Promise<{ message: string }> => { const response = await axiosClient.delete<{ message: string }>( - `${CHAINAGES_ENDPOINT}/${chainageId}`, + API_ROUTES.CHAINAGES.DETAIL(chainageId), ); return response.data; }, diff --git a/src/services/api/client.service.ts b/src/services/api/client.service.ts index 2f28b0d..2c1b648 100644 --- a/src/services/api/client.service.ts +++ b/src/services/api/client.service.ts @@ -1,8 +1,7 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import type { Client, ClientListParams, ClientListResponse, ClientRequest } from '@/types'; -const CLIENTS_ENDPOINT = 'api/clients'; - function toClientPayload(payload: ClientRequest) { return { name: payload.name, @@ -21,7 +20,7 @@ function toClientPayload(payload: ClientRequest) { export const clientService = { getClients: async (params?: ClientListParams): Promise => { - const response = await axiosClient.get(CLIENTS_ENDPOINT, { + const response = await axiosClient.get(API_ROUTES.CLIENTS.BASE, { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 10, @@ -38,20 +37,20 @@ export const clientService = { }, getClientById: async (id: number): Promise => { - const response = await axiosClient.get(`${CLIENTS_ENDPOINT}/${id}`); + const response = await axiosClient.get(API_ROUTES.CLIENTS.DETAIL(id)); return response.data; }, saveClient: async (payload: ClientRequest): Promise => { const requestPayload = toClientPayload(payload); const response = payload.id - ? await axiosClient.put(`${CLIENTS_ENDPOINT}/${payload.id}`, requestPayload) - : await axiosClient.post(CLIENTS_ENDPOINT, requestPayload); + ? await axiosClient.put(API_ROUTES.CLIENTS.DETAIL(payload.id), requestPayload) + : await axiosClient.post(API_ROUTES.CLIENTS.BASE, requestPayload); return response.data; }, updateClientStatus: async (id: number, isActive: boolean): Promise => { - const response = await axiosClient.patch(`${CLIENTS_ENDPOINT}/${id}/status`, { + const response = await axiosClient.patch(API_ROUTES.CLIENTS.STATUS(id), { is_active: isActive, }); return response.data; diff --git a/src/services/api/detection.service.ts b/src/services/api/detection.service.ts index 9246e76..a37c820 100644 --- a/src/services/api/detection.service.ts +++ b/src/services/api/detection.service.ts @@ -1,4 +1,5 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import { Detection } from '@/types'; import { projectService } from './project.service'; @@ -31,7 +32,7 @@ export const detectionService = { }; }; }; - }>('biz/api/v1/dashboard/overview', { + }>(API_ROUTES.DASHBOARD.OVERVIEW, { params: { project_id: project.id }, }); diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 71f7685..9389cc4 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -1,11 +1,11 @@ export * from './project.service'; +export * from './project-summary.service'; export * from './package.service'; export * from './auth.service'; export { chainageService } from './chainage.service'; export * from './video.service'; export * from './detection.service'; export * from './session.service'; -export { projectDataService } from './project.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 5c44540..f4cc010 100644 --- a/src/services/api/package.service.ts +++ b/src/services/api/package.service.ts @@ -1,4 +1,5 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import { Package, PackageCreate, @@ -7,8 +8,6 @@ import { PaginationParams, } from '@/types'; -const PACKAGES_ENDPOINT = 'biz/api/v1/packages'; - /** * Package Service */ @@ -19,7 +18,7 @@ export const packageService = { getPackages: async (params?: PaginationParams): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(PACKAGES_ENDPOINT, { + const response = await axiosClient.get>(API_ROUTES.PACKAGES.BASE, { params: { skip, limit }, }); return response.data; @@ -34,7 +33,7 @@ export const packageService = { ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(PACKAGES_ENDPOINT, { + const response = await axiosClient.get>(API_ROUTES.PACKAGES.BASE, { params: { project_id: projectId, skip, limit }, }); return response.data; @@ -44,7 +43,7 @@ export const packageService = { * Create a new package */ createPackage: async (data: PackageCreate): Promise => { - const response = await axiosClient.post(PACKAGES_ENDPOINT, data); + const response = await axiosClient.post(API_ROUTES.PACKAGES.BASE, data); return response.data; }, @@ -52,7 +51,7 @@ export const packageService = { * Update an existing package */ updatePackage: async (packageId: string, data: PackageUpdate): Promise => { - const response = await axiosClient.put(`${PACKAGES_ENDPOINT}/${packageId}`, data); + const response = await axiosClient.put(API_ROUTES.PACKAGES.DETAIL(packageId), data); return response.data; }, @@ -61,7 +60,7 @@ export const packageService = { */ deletePackage: async (packageId: string): Promise<{ message: string }> => { const response = await axiosClient.delete<{ message: string }>( - `${PACKAGES_ENDPOINT}/${packageId}`, + API_ROUTES.PACKAGES.DETAIL(packageId), ); return response.data; }, diff --git a/src/services/api/permission.service.ts b/src/services/api/permission.service.ts index 261d7b0..9748099 100644 --- a/src/services/api/permission.service.ts +++ b/src/services/api/permission.service.ts @@ -1,15 +1,18 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import type { PermissionResponse, PermissionTreeNode } from '@/types'; export const permissionService = { getPermissions: async (): Promise => { - const response = await axiosClient.get('api/permissions/my-permissions'); + const response = await axiosClient.get( + API_ROUTES.PERMISSIONS.MY_PERMISSIONS, + ); return response.data; }, getOrganizationPermissionTree: async (): Promise => { const response = await axiosClient.get( - 'api/permissions/organization-tree', + API_ROUTES.PERMISSIONS.ORGANIZATION_TREE, ); return response.data; }, diff --git a/src/services/api/plan.service.ts b/src/services/api/plan.service.ts index d17533a..b8588a2 100644 --- a/src/services/api/plan.service.ts +++ b/src/services/api/plan.service.ts @@ -1,8 +1,7 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import type { Plan, PlanListParams, PlanListResponse, PlanRequest } from '@/types'; -const PLANS_ENDPOINT = 'api/superadmin/plans'; - function toPlanPayload(payload: PlanRequest) { return { name: payload.name, @@ -23,7 +22,7 @@ function toPlanPayload(payload: PlanRequest) { export const planService = { getPlans: async (params?: PlanListParams): Promise => { - const response = await axiosClient.get(PLANS_ENDPOINT, { + const response = await axiosClient.get(API_ROUTES.PLANS.BASE, { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 10, @@ -42,13 +41,13 @@ export const planService = { savePlan: async (payload: PlanRequest): Promise => { const requestPayload = toPlanPayload(payload); const response = payload.id - ? await axiosClient.put(`${PLANS_ENDPOINT}/${payload.id}`, requestPayload) - : await axiosClient.post(PLANS_ENDPOINT, requestPayload); + ? await axiosClient.put(API_ROUTES.PLANS.DETAIL(payload.id), requestPayload) + : await axiosClient.post(API_ROUTES.PLANS.BASE, requestPayload); return response.data; }, updatePlanStatus: async (id: number, isActive: boolean): Promise => { - const response = await axiosClient.patch(`${PLANS_ENDPOINT}/${id}/status`, { + const response = await axiosClient.patch(API_ROUTES.PLANS.STATUS(id), { is_active: isActive, }); return response.data; diff --git a/src/services/api/project-summary.service.ts b/src/services/api/project-summary.service.ts new file mode 100644 index 0000000..df6efab --- /dev/null +++ b/src/services/api/project-summary.service.ts @@ -0,0 +1,52 @@ +import { API_ROUTES } from '@/constants/apiRoutes'; + +import axiosClient from '../axios/axios'; + +export const projectSummaryService = { + getProjectSummary: async (projectId: string): Promise => { + const response = await axiosClient.get(API_ROUTES.DASHBOARD.OVERVIEW, { + params: { project_id: projectId }, + }); + return response.data; + }, + + getProjectSummaryByVideo: async ( + projectId: string, + videoId: string, + ): Promise => { + const response = await axiosClient.get(API_ROUTES.DASHBOARD.OVERVIEW, { + params: { project_id: projectId, video_id: videoId }, + }); + return response.data; + }, +}; + +export const projectDataService = { + extractDetections( + projectSummary: any, + selectedPackageId?: string | null, + selectedChainageId?: string | null, + ): any[] { + if (!projectSummary) return []; + + const detections: any[] = []; + const packagesToProcess = + selectedPackageId && selectedPackageId !== 'all' + ? { [selectedPackageId]: projectSummary.packages[selectedPackageId] } + : projectSummary.packages || {}; + + for (const pkg of Object.values(packagesToProcess)) { + const chainagesToProcess = + selectedChainageId && selectedChainageId !== 'all' + ? { [selectedChainageId]: (pkg as any).chainages[selectedChainageId] } + : (pkg as any).chainages || {}; + + for (const chainage of Object.values(chainagesToProcess)) { + if (!chainage) continue; + detections.push(...((chainage as any).detections || [])); + } + } + + return detections; + }, +}; diff --git a/src/services/api/project.service.ts b/src/services/api/project.service.ts index c339f72..5f093bb 100644 --- a/src/services/api/project.service.ts +++ b/src/services/api/project.service.ts @@ -1,3 +1,4 @@ +import { API_ROUTES } from '@/constants/apiRoutes'; import axiosClient from '../axios/axios'; import { Project, @@ -7,9 +8,6 @@ import { PaginationParams, } from '@/types'; -const PROJECTS_ENDPOINT = 'biz/api/v1/projects'; -const PROJECT_SUMMARY_ENDPOINT = 'biz/api/v1/dashboard/overview'; - /** * Project Service */ @@ -20,7 +18,7 @@ export const projectService = { getProjects: async (params?: PaginationParams): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(PROJECTS_ENDPOINT, { + const response = await axiosClient.get>(API_ROUTES.PROJECTS.BASE, { params: { skip, limit }, }); return response.data; @@ -30,7 +28,7 @@ export const projectService = { * Create a new project */ createProject: async (data: ProjectCreate): Promise => { - const response = await axiosClient.post(PROJECTS_ENDPOINT, data); + const response = await axiosClient.post(API_ROUTES.PROJECTS.BASE, data); return response.data; }, @@ -38,7 +36,7 @@ export const projectService = { * Update an existing project */ updateProject: async (projectId: string, data: ProjectUpdate): Promise => { - const response = await axiosClient.put(`${PROJECTS_ENDPOINT}/${projectId}`, data); + const response = await axiosClient.put(API_ROUTES.PROJECTS.DETAIL(projectId), data); return response.data; }, @@ -47,66 +45,8 @@ export const projectService = { */ deleteProject: async (projectId: string): Promise<{ message: string }> => { const response = await axiosClient.delete<{ message: string }>( - `${PROJECTS_ENDPOINT}/${projectId}`, + API_ROUTES.PROJECTS.DETAIL(projectId), ); return response.data; }, - - /** - * Fetch project summary (detections across packages and chainages) - */ - getProjectSummary: async (projectId: string): Promise => { - const response = await axiosClient.get(PROJECT_SUMMARY_ENDPOINT, { - params: { project_id: projectId }, - }); - return response.data; - }, - - /** - * Fetch project summary filtered by video ID - */ - getProjectSummaryByVideo: async (projectId: string, videoId: string): Promise => { - const response = await axiosClient.get(PROJECT_SUMMARY_ENDPOINT, { - params: { project_id: projectId, video_id: videoId }, - }); - return response.data; - }, -}; - -/** - * Service to handle data extraction from project summaries - * Moved from legacy project-service.ts - */ -export const projectDataService = { - /** - * Extracts all detections from a project summary, optionally filtered by package and chainage - */ - extractDetections( - projectSummary: any, - selectedPackageId?: string | null, - selectedChainageId?: string | null, - ): any[] { - if (!projectSummary) return []; - - const detections: any[] = []; - const packagesToProcess = - selectedPackageId && selectedPackageId !== 'all' - ? { [selectedPackageId]: projectSummary.packages[selectedPackageId] } - : projectSummary.packages || {}; - - for (const [pkgName, pkg] of Object.entries(packagesToProcess)) { - const chainagesToProcess = - selectedChainageId && selectedChainageId !== 'all' - ? { [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 || []; - detections.push(...chainageDetections); - } - } - - return detections; - }, }; diff --git a/src/services/api/role.service.ts b/src/services/api/role.service.ts index 7311262..43bee75 100644 --- a/src/services/api/role.service.ts +++ b/src/services/api/role.service.ts @@ -1,11 +1,10 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import type { Role, RoleListParams, RoleListResponse, RoleRequest } from '@/types'; -const ROLES_ENDPOINT = 'api/roles'; - export const roleService = { getRoles: async (params?: RoleListParams): Promise => { - const response = await axiosClient.get(ROLES_ENDPOINT, { + const response = await axiosClient.get(API_ROUTES.ROLES.BASE, { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 10, @@ -22,19 +21,19 @@ export const roleService = { }, getRoleById: async (id: number): Promise => { - const response = await axiosClient.get(`${ROLES_ENDPOINT}/${id}`); + const response = await axiosClient.get(API_ROUTES.ROLES.DETAIL(id)); return response.data; }, saveRole: async (payload: RoleRequest): Promise => { const response = payload.id - ? await axiosClient.put(ROLES_ENDPOINT, payload) - : await axiosClient.post(ROLES_ENDPOINT, payload); + ? await axiosClient.put(API_ROUTES.ROLES.BASE, payload) + : await axiosClient.post(API_ROUTES.ROLES.BASE, payload); return response.data; }, updateRoleStatus: async (id: number, isActive: boolean): Promise => { - const response = await axiosClient.patch(`${ROLES_ENDPOINT}/${id}/status`, { + const response = await axiosClient.patch(API_ROUTES.ROLES.STATUS(id), { is_active: isActive, }); return response.data; diff --git a/src/services/api/tenant.service.ts b/src/services/api/tenant.service.ts index 8536816..db4d85a 100644 --- a/src/services/api/tenant.service.ts +++ b/src/services/api/tenant.service.ts @@ -1,4 +1,5 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import type { Tenant, TenantCreateRequest, @@ -7,8 +8,6 @@ import type { TenantUpdateRequest, } from '@/types'; -const TENANTS_ENDPOINT = 'api/superadmin/tenants'; - function toCreatePayload(payload: TenantCreateRequest) { return { name: payload.name, @@ -39,7 +38,7 @@ function toUpdatePayload(payload: TenantUpdateRequest) { export const tenantService = { getTenants: async (params?: TenantListParams): Promise => { - const response = await axiosClient.get(TENANTS_ENDPOINT, { + const response = await axiosClient.get(API_ROUTES.TENANTS.BASE, { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 10, @@ -53,19 +52,22 @@ export const tenantService = { }, createTenant: async (payload: TenantCreateRequest): Promise => { - const response = await axiosClient.post(TENANTS_ENDPOINT, toCreatePayload(payload)); + const response = await axiosClient.post( + API_ROUTES.TENANTS.BASE, + toCreatePayload(payload), + ); return response.data; }, updateTenant: async (payload: TenantUpdateRequest): Promise => { const response = await axiosClient.put( - `${TENANTS_ENDPOINT}/${payload.id}`, + API_ROUTES.TENANTS.DETAIL(payload.id), toUpdatePayload(payload), ); return response.data; }, deleteTenant: async (id: number): Promise => { - await axiosClient.delete(`${TENANTS_ENDPOINT}/${id}`); + await axiosClient.delete(API_ROUTES.TENANTS.DETAIL(id)); }, }; diff --git a/src/services/api/user.service.ts b/src/services/api/user.service.ts index 6cfe005..0dc6fcc 100644 --- a/src/services/api/user.service.ts +++ b/src/services/api/user.service.ts @@ -1,11 +1,10 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import type { AdministrationUser, UserListParams, UserListResponse, UserRequest } from '@/types'; -const USERS_ENDPOINT = 'api/users'; - export const userService = { getUsers: async (params?: UserListParams): Promise => { - const response = await axiosClient.get(USERS_ENDPOINT, { + const response = await axiosClient.get(API_ROUTES.USERS.BASE, { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 10, @@ -24,8 +23,8 @@ export const userService = { saveUser: async (payload: UserRequest): Promise => { const response = payload.id - ? await axiosClient.put(USERS_ENDPOINT, payload) - : await axiosClient.post(USERS_ENDPOINT, payload); + ? await axiosClient.put(API_ROUTES.USERS.BASE, payload) + : await axiosClient.post(API_ROUTES.USERS.BASE, payload); return response.data; }, @@ -33,7 +32,7 @@ export const userService = { id: number, status: 'active' | 'inactive', ): Promise => { - const response = await axiosClient.patch(`${USERS_ENDPOINT}/${id}/status`, { + const response = await axiosClient.patch(API_ROUTES.USERS.STATUS(id), { status, }); return response.data; diff --git a/src/services/api/video.service.ts b/src/services/api/video.service.ts index f198488..ceb633f 100644 --- a/src/services/api/video.service.ts +++ b/src/services/api/video.service.ts @@ -1,4 +1,5 @@ import axiosClient from '../axios/axios'; +import { API_ROUTES } from '@/constants/apiRoutes'; import { Video, PaginationParams } from '@/types'; /** @@ -27,7 +28,7 @@ export const videoService = { total_detections?: number; }; }>; - }>(`/videos`, { + }>(API_ROUTES.VIDEOS.LIST, { params: { skip, limit }, }); @@ -53,7 +54,7 @@ export const videoService = { * Upload a video for processing */ uploadVideo: async (formData: FormData): Promise => { - const response = await axiosClient.post('/upload', formData, { + const response = await axiosClient.post(API_ROUTES.VIDEOS.UPLOAD, formData, { headers: { 'Content-Type': 'multipart/form-data', }, @@ -65,7 +66,7 @@ export const videoService = { * Get processing status of a video */ getVideoStatus: async (videoId: string): Promise => { - const response = await axiosClient.get(`/status/${videoId}`); + const response = await axiosClient.get(API_ROUTES.VIDEOS.STATUS(videoId)); return response.data; }, @@ -73,7 +74,7 @@ export const videoService = { * Get analysis results for a video */ getVideoResults: async (videoId: string): Promise => { - const response = await axiosClient.get(`/results/${videoId}`); + const response = await axiosClient.get(API_ROUTES.VIDEOS.RESULTS(videoId)); return response.data; }, };