refactor: remove static routes and stop multiple token api call in sse

This commit is contained in:
2026-06-23 22:48:34 +05:30
parent b282fdf56d
commit 5e4cf61d6e
25 changed files with 129 additions and 2086 deletions

51
package-lock.json generated
View File

@@ -26,7 +26,6 @@
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"libphonenumber-js": "^1.13.7",
"lucide-react": "^0.548.0",
"motion": "^12.38.0",
@@ -36,7 +35,6 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.79.0",
"react-leaflet": "^5.0.0",
"recharts": "2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^3.3.1",
@@ -45,7 +43,6 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/leaflet": "^1.9.21",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -2882,17 +2879,6 @@
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"license": "MIT"
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3380,13 +3366,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3401,16 +3380,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -6851,12 +6820,6 @@
"node": ">=0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -8024,20 +7987,6 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",

View File

@@ -30,7 +30,6 @@
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"libphonenumber-js": "^1.13.7",
"lucide-react": "^0.548.0",
"motion": "^12.38.0",
@@ -40,7 +39,6 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.79.0",
"react-leaflet": "^5.0.0",
"recharts": "2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^3.3.1",
@@ -49,7 +47,6 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/leaflet": "^1.9.21",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@@ -1,678 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from '@/components/ui/card';
import {
AlertCircle,
Activity,
MapPin as MapPinIcon,
Milestone,
BarChart3,
AlertTriangle,
Zap,
PencilLine,
CheckCircle2,
Settings2,
Droplets,
} from 'lucide-react';
import { StatsCard } from '@/components/dashboard/stats-card';
import { FilterSelector } from '@/components/dashboard/filter-selector';
import { DetectionDonutChart } from '@/components/dashboard/detection-donut-chart';
import { ChainageBarChart } from '@/components/dashboard/chainage-bar-chart';
import { DashboardMap } from '@/components/dashboard/dashboard-map';
import { PageHeader } from '@/components/page-header';
import { projectService, projectSummaryService } from '@/services/api';
import { Project } from '@/types';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
import { toast } from 'sonner';
import { Reveal, StaggerContainer, StaggerItem } from '@/components/ui/reveal';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
interface ProjectSummary {
project: {
id: string;
name: string;
corridor_name: string | null;
state: string | null;
};
packages: {
[key: string]: {
package_id: string;
region: string | null;
chainages: {
[key: string]: {
chainage_id: string;
chainage: string | null;
detection_count: number;
detections: Array<{
id: number;
type: string;
class: string;
confidence: number;
latitude: number;
longitude: number;
}>;
};
};
};
};
}
interface DetectionStats {
totalDefectedSignboard: number;
totalPothole: number;
totalRoadCrack: number;
totalDamagedRoadMarking: number;
totalGoodSignboard: number;
totalDrainIssue: number;
totalDefectiveCulvert: number;
totalRoadDamage: number;
chainageData: Array<{
name: string;
defected_sign_board: number;
pothole: number;
road_crack: number;
damaged_road_marking: number;
// good_sign_board: number;
drain_issue: number;
defective_culvert: number;
total: number;
}>;
}
function calculateStats(summary: ProjectSummary | null): DetectionStats {
if (!summary) {
return {
totalDefectedSignboard: 0,
totalPothole: 0,
totalRoadCrack: 0,
totalDamagedRoadMarking: 0,
totalGoodSignboard: 0,
totalDrainIssue: 0,
totalDefectiveCulvert: 0,
totalRoadDamage: 0,
chainageData: [],
};
}
let total_defected_sign_board = 0;
let total_pothole = 0;
let total_road_crack = 0;
let total_damaged_road_marking = 0;
let total_good_sign_board = 0;
let total_drain_issue = 0;
let total_defective_culvert = 0;
let total_road_damage = 0;
const chainageData: DetectionStats['chainageData'] = [];
for (const pkg of Object.values(summary.packages || {})) {
for (const [chnName, chn] of Object.entries(pkg.chainages || {})) {
let chnDefectedSignboard = 0;
let chnPothole = 0;
let chnRoadCrack = 0;
let chnDamagedRoadMarking = 0;
let chnGoodSignboard = 0;
let chnDrainIssue = 0;
let chnDefectiveCulvert = 0;
for (const detection of chn.detections || []) {
const type = (detection.type || '').toLowerCase();
if (type === 'defected_sign_board') {
chnDefectedSignboard++;
total_defected_sign_board++;
} else if (type === 'pothole') {
chnPothole++;
total_pothole++;
} else if (type === 'road_crack') {
chnRoadCrack++;
total_road_crack++;
} else if (type === 'damaged_road_marking') {
chnDamagedRoadMarking++;
total_damaged_road_marking++;
} else if (type === 'good_sign_board') {
chnGoodSignboard++;
// total_good_sign_board++;
} else if (type === 'drain_issue') {
chnDrainIssue++;
total_drain_issue++;
} else if (type === 'defective_culvert') {
chnDefectiveCulvert++;
total_defective_culvert++;
}
}
if (
chnDefectedSignboard > 0 ||
chnPothole > 0 ||
chnRoadCrack > 0 ||
chnDamagedRoadMarking > 0 ||
chnGoodSignboard > 0 ||
chnDrainIssue > 0 ||
chnDefectiveCulvert > 0
) {
const shortName =
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
chainageData.push({
name: shortName,
defected_sign_board: chnDefectedSignboard,
pothole: chnPothole,
road_crack: chnRoadCrack,
damaged_road_marking: chnDamagedRoadMarking,
// good_sign_board: chnGoodSignboard,
drain_issue: chnDrainIssue,
defective_culvert: chnDefectiveCulvert,
total:
chnDefectedSignboard +
chnPothole +
chnRoadCrack +
chnDamagedRoadMarking +
// chnGoodSignboard +
chnDrainIssue +
chnDefectiveCulvert,
});
}
}
}
total_road_damage =
total_defected_sign_board +
total_pothole +
total_road_crack +
total_damaged_road_marking +
total_drain_issue +
total_defective_culvert;
return {
totalDefectedSignboard: total_defected_sign_board,
totalPothole: total_pothole,
totalRoadCrack: total_road_crack,
totalDamagedRoadMarking: total_damaged_road_marking,
totalGoodSignboard: total_good_sign_board,
totalDrainIssue: total_drain_issue,
totalDefectiveCulvert: total_defective_culvert,
totalRoadDamage: total_road_damage,
chainageData,
};
}
export default function DashboardPage() {
const [isLoading, setIsLoading] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
null,
);
const [stats, setStats] = useState<DetectionStats>({
totalDefectedSignboard: 0,
totalPothole: 0,
totalRoadCrack: 0,
totalDamagedRoadMarking: 0,
totalGoodSignboard: 0,
totalDrainIssue: 0,
totalDefectiveCulvert: 0,
totalRoadDamage: 0,
chainageData: [],
});
// Load projects on mount
useEffect(() => {
const loadProjects = async () => {
try {
setIsLoading(true);
const projectsData = await projectService.getProjects();
setProjects(projectsData.items);
if (projectsData.items.length > 0) {
setSelectedProjectId(
projectsData.items[projectsData.items.length - 1].id,
);
}
} catch (err) {
console.error('Failed to load projects:', err);
toast.error('Failed to load projects', {
description: 'Please check if the backend is running.',
});
} finally {
setTimeout(() => {
setIsLoading(false);
}, 1500);
}
};
loadProjects();
}, []);
const selectedProject = projects.find((p) => p.id === selectedProjectId);
// Extract packages from project summary
const packages = projectSummary
? Object.keys(projectSummary.packages || {}).map((pkgName) => ({
id: pkgName,
name: pkgName,
}))
: [];
// Extract chainages from selected package
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
null,
);
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(
null,
); // Changed 'selectedLocationId' to 'selectedChainageId'
const chainages =
projectSummary && selectedPackageId && selectedPackageId !== 'all'
? Object.keys(
projectSummary.packages[selectedPackageId]?.chainages || {},
).map((chnName) => ({
id: chnName,
name: chnName,
}))
: [];
// Load project summary when project changes
useEffect(() => {
if (!selectedProjectId) {
setProjectSummary(null);
return;
}
setProjectSummary(null);
const loadProjectSummary = async () => {
try {
setIsLoading(true);
const summary =
await projectSummaryService.getProjectSummary<ProjectSummary>(
selectedProjectId,
);
setProjectSummary(summary);
} catch (err) {
console.error('Failed to load project summary:', err);
toast.error('Failed to load project summary');
setProjectSummary(null);
} finally {
setTimeout(() => {
setIsLoading(false);
}, 1500);
}
};
loadProjectSummary();
}, [selectedProjectId]);
// Reset package and chainage when project changes
useEffect(() => {
setSelectedPackageId(null);
setSelectedChainageId(null);
}, [selectedProjectId]);
// Auto-select last package when project summary is loaded
useEffect(() => {
if (projectSummary && selectedPackageId === null) {
const packageIds = Object.keys(projectSummary.packages || {});
if (packageIds.length > 0) {
setSelectedPackageId(packageIds[packageIds.length - 1]);
}
}
}, [projectSummary, selectedPackageId]);
// Reset chainage when package changes
useEffect(() => {
setSelectedChainageId(null);
}, [selectedPackageId]);
// Auto-select last chainage when package is selected
useEffect(() => {
if (
projectSummary &&
selectedPackageId &&
selectedPackageId !== 'all' &&
selectedChainageId === null
) {
const chainageIds = Object.keys(
projectSummary.packages[selectedPackageId]?.chainages || {},
);
if (chainageIds.length > 0) {
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
}
}
}, [projectSummary, selectedPackageId, selectedChainageId]);
// Filter stats based on selections
useEffect(() => {
if (!projectSummary) {
setStats({
totalDefectedSignboard: 0,
totalPothole: 0,
totalRoadCrack: 0,
totalDamagedRoadMarking: 0,
totalGoodSignboard: 0,
totalDrainIssue: 0,
totalDefectiveCulvert: 0,
totalRoadDamage: 0,
chainageData: [], // Changed 'locationData' to 'chainageData'
});
return;
}
let totalDefectedSignboard = 0;
let totalPothole = 0;
let totalRoadCrack = 0;
let totalDamagedRoadMarking = 0;
let totalGoodSignboard = 0;
let totalDrainIssue = 0;
let totalDefectiveCulvert = 0;
const chainageData: DetectionStats['chainageData'] = [];
const packagesToProcess =
selectedPackageId && selectedPackageId !== 'all'
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
: projectSummary.packages || {};
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
if (!pkg) continue;
const chainagesToProcess =
selectedChainageId && selectedChainageId !== 'all'
? { [selectedChainageId]: pkg.chainages?.[selectedChainageId] }
: pkg.chainages || {};
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
if (!chn) continue;
let chnDefectedSignboard = 0;
let chnPothole = 0;
let chnRoadCrack = 0;
let chnDamagedRoadMarking = 0;
let chnGoodSignboard = 0;
let chnDrainIssue = 0;
let chnDefectiveCulvert = 0;
for (const detection of chn.detections || []) {
const type = (detection.type || '').toLowerCase();
if (type === 'defected_sign_board') {
chnDefectedSignboard++;
totalDefectedSignboard++;
} else if (type === 'pothole') {
chnPothole++;
totalPothole++;
} else if (type === 'road_crack') {
chnRoadCrack++;
totalRoadCrack++;
} else if (type === 'damaged_road_marking') {
chnDamagedRoadMarking++;
totalDamagedRoadMarking++;
} else if (type === 'good_sign_board') {
// chnGoodSignboard++;
// totalGoodSignboard++;
} else if (type === 'drain_issue') {
chnDrainIssue++;
totalDrainIssue++;
} else if (type === 'defective_culvert') {
chnDefectiveCulvert++;
totalDefectiveCulvert++;
}
}
if (
chnDefectedSignboard > 0 ||
chnPothole > 0 ||
chnRoadCrack > 0 ||
chnDamagedRoadMarking > 0 ||
chnGoodSignboard > 0 ||
chnDrainIssue > 0 ||
chnDefectiveCulvert > 0
) {
const shortName =
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
chainageData.push({
name: shortName,
defected_sign_board: chnDefectedSignboard,
pothole: chnPothole,
road_crack: chnRoadCrack,
damaged_road_marking: chnDamagedRoadMarking,
// good_sign_board: chnGoodSignboard,
drain_issue: chnDrainIssue,
defective_culvert: chnDefectiveCulvert,
total:
chnDefectedSignboard +
chnPothole +
chnRoadCrack +
chnDamagedRoadMarking +
// chnGoodSignboard +
chnDrainIssue +
chnDefectiveCulvert,
});
}
}
}
setStats({
totalDefectedSignboard,
totalPothole,
totalRoadCrack,
totalDamagedRoadMarking,
totalGoodSignboard,
totalDrainIssue,
totalDefectiveCulvert,
totalRoadDamage:
totalDefectedSignboard +
totalPothole +
totalRoadCrack +
totalDamagedRoadMarking +
totalDrainIssue +
totalDefectiveCulvert,
chainageData,
});
}, [projectSummary, selectedPackageId, selectedChainageId]);
return (
<>
{/* Main Content */}
<main>
<div>
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<PageHeader
title="Dashboard"
description="A Comprehensive Overview Of Your Road Infrastructure Analysis"
icon={BarChart3}
/>
<Reveal direction="down" delay={0.2}>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2 font-semibold">
<Settings2 className="h-4 w-4" />
<span>Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[320px] p-0 overflow-hidden"
align="end"
>
<div className="p-4 border-b bg-muted/30">
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
<Milestone className="h-4 w-4 text-primary" />
Filter Analysis
</h3>
<p className="text-xs mt-1 text-muted-foreground">
Refine your view by project, package, or segment
</p>
</div>
<div>
<FilterSelector
projects={projects}
selectedProjectId={selectedProjectId}
selectedPackageId={selectedPackageId}
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
onProjectChange={setSelectedProjectId}
onPackageChange={setSelectedPackageId}
onChainageChange={setSelectedChainageId} // Changed 'onLocationChange' to 'onChainageChange'
packages={packages}
chainages={chainages} // Changed 'locations' to 'chainages'
isLoading={isLoading}
/>
</div>
</PopoverContent>
</Popover>
</Reveal>
</div>
{isLoading && !projectSummary ? (
<DashboardSkeleton />
) : (
<>
{/* Stats Cards - Top Row */}
<StaggerContainer className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<StaggerItem>
<StatsCard
title="Total Road Damage"
subtitle="Combined damage detections"
value={stats.totalRoadDamage}
icon={Activity}
/>
</StaggerItem>
<StaggerItem>
<StatsCard
title="Potholes"
subtitle="Surface depressions"
value={stats.totalPothole}
icon={AlertCircle}
/>
</StaggerItem>
<StaggerItem>
<StatsCard
title="Defected Signboards"
subtitle="Damaged traffic signs"
value={stats.totalDefectedSignboard}
icon={AlertTriangle}
/>
</StaggerItem>
<StaggerItem>
<StatsCard
title="Road Cracks"
subtitle="Surface fissures"
value={stats.totalRoadCrack}
icon={Zap}
/>
</StaggerItem>
<StaggerItem>
<StatsCard
title="Damaged Markings"
subtitle="Worn road lines"
value={stats.totalDamagedRoadMarking}
icon={PencilLine}
/>
</StaggerItem>
<StaggerItem>
<StatsCard
title="Drain Issues"
subtitle="Clogged or broken drainage"
value={stats.totalDrainIssue}
icon={Droplets}
/>
</StaggerItem>
{/* <StaggerItem>
<StatsCard
title="Good Signboards"
subtitle="Informational markers"
value={stats.totalGoodSignboard}
icon={CheckCircle2}
/>
</StaggerItem> */}
</StaggerContainer>
{/* Charts Row - Side by Side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<Reveal delay={0.2} direction="left" className="flex flex-col">
<Card className="flex-1 h-full">
<CardHeader className="items-center pb-0">
<CardTitle>Detection Distribution</CardTitle>
<CardDescription>
Breakdown of all detected road conditions
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<DetectionDonutChart
defectedSignboard={stats.totalDefectedSignboard}
pothole={stats.totalPothole}
roadCrack={stats.totalRoadCrack}
damagedRoadMarking={stats.totalDamagedRoadMarking}
// goodSignboard={stats.totalGoodSignboard}
drainIssue={stats.totalDrainIssue}
defectiveCulvert={stats.totalDefectiveCulvert}
/>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="leading-none text-muted-foreground">
Total detections:{' '}
{stats.totalRoadDamage +
// stats.totalGoodSignboard +
stats.totalDrainIssue +
stats.totalDefectiveCulvert}
</div>
</CardFooter>
</Card>
</Reveal>
<Reveal delay={0.3} direction="right" className="flex flex-col">
<Card className="flex-1 h-full">
<CardHeader className="items-center pb-0">
<CardTitle className="text-base font-bold">
Severity by Segment
</CardTitle>
<CardDescription className="text-xs">
Detections grouped by road segment
</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ChainageBarChart
data={stats.chainageData || []}
isLoading={isLoading}
/>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="leading-none text-muted-foreground">
Analysis based on latest processed sequence
</div>
</CardFooter>
</Card>
</Reveal>
</div>
{/* Map - Full Width Below Charts */}
<Reveal delay={0.4} direction="up">
<div>
<DashboardMap
selectedProjectId={selectedProjectId}
selectedPackageId={selectedPackageId}
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
projectSummary={projectSummary}
/>
</div>
</Reveal>
</>
)}
</div>
</main>
</>
);
}

View File

@@ -1,6 +1,18 @@
import { redirect } from 'next/navigation';
import { ROUTES } from '@/utils/routes';
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { AppLoader } from '@/components/AppLoader';
import { useAppStore } from '@/store/app.store';
export default function HomePage() {
redirect(ROUTES.DASHBOARD);
const router = useRouter();
const defaultRoute = useAppStore((state) => state.defaultRoute);
useEffect(() => {
router.replace(defaultRoute ?? '/access');
}, [defaultRoute, router]);
return <AppLoader label="Opening workspace" />;
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { API_ROUTES } from '@/constants/apiRoutes';
@@ -125,6 +125,7 @@ function mergeTicketListItem(
export function useTenantTicketTableEvents(enabled = true) {
const queryClient = useQueryClient();
const hasInvalidatedAfterConnectionErrorRef = useRef(false);
const events = useMemo(
() => ({
@@ -154,14 +155,22 @@ export function useTenantTicketTableEvents(enabled = true) {
);
const onConnectionError = useCallback(() => {
if (hasInvalidatedAfterConnectionErrorRef.current) return;
hasInvalidatedAfterConnectionErrorRef.current = true;
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, [queryClient]);
const onConnectionOpen = useCallback(() => {
hasInvalidatedAfterConnectionErrorRef.current = false;
}, []);
useSseWithToken({
enabled,
getToken: ticketService.createTenantTicketEventsToken,
getPath,
events,
onConnectionError,
onConnectionOpen,
});
}

View File

@@ -1,7 +1,6 @@
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { ROUTES } from '@/utils/routes';
export default function NotFound() {
return (
@@ -13,7 +12,7 @@ export default function NotFound() {
The page you are looking for does not exist or may have been moved.
</p>
<Button asChild className="mt-6">
<Link href={ROUTES.DASHBOARD}>Go to dashboard</Link>
<Link href="/">Go to workspace</Link>
</Button>
</div>
</main>

View File

@@ -1,143 +0,0 @@
'use client';
import * as React from 'react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Loader2 } from 'lucide-react';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
interface ChainageData {
name: string;
defected_sign_board: number;
pothole: number;
road_crack: number;
damaged_road_marking: number;
drain_issue: number;
defective_culvert: number;
total: number;
}
interface ChainageBarChartProps {
data: ChainageData[];
isLoading?: boolean;
}
const chartConfig = {
pothole: {
label: DETECTION_TYPES.pothole.label + 's',
color: DETECTION_TYPES.pothole.color,
},
defected_sign_board: {
label: DETECTION_TYPES.defected_sign_board.label + 's',
color: DETECTION_TYPES.defected_sign_board.color,
},
road_crack: {
label: DETECTION_TYPES.road_crack.label + 's',
color: DETECTION_TYPES.road_crack.color,
},
damaged_road_marking: {
label: DETECTION_TYPES.damaged_road_marking.label + 's',
color: DETECTION_TYPES.damaged_road_marking.color,
},
drain_issue: {
label: DETECTION_TYPES.drain_issue.label + 's',
color: DETECTION_TYPES.drain_issue.color,
},
defective_culvert: {
label: DETECTION_TYPES.defective_culvert.label + 's',
color: DETECTION_TYPES.defective_culvert.color,
},
} satisfies ChartConfig;
export function ChainageBarChart({
data,
isLoading = false,
}: ChainageBarChartProps) {
if (isLoading) {
return (
<div className="h-[250px] flex items-center justify-center">
<Loader2 className="h-8 w-8 text-primary/50 animate-spin" />
</div>
);
}
if (data.length === 0) {
return (
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
<p className="text-sm">No segment data available</p>
<p className="text-xs mt-1">
Process videos to see detections by segment
</p>
</div>
);
}
// Filter out good signboards for a more "damage-focused" standard chart as per multiple bar example
const chartData = data.map((item) => ({
name: item.name,
pothole: item.pothole,
defected_sign_board: item.defected_sign_board,
road_crack: item.road_crack,
damaged_road_marking: item.damaged_road_marking,
drain_issue: item.drain_issue,
defective_culvert: item.defective_culvert,
}));
return (
<div className="h-[250px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} strokeOpacity={0.1} />
<XAxis
dataKey="name"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
tickMargin={10}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dashed" />}
/>
<Bar dataKey="pothole" fill="var(--color-pothole)" radius={4} />
<Bar
dataKey="defected_sign_board"
fill="var(--color-defected_sign_board)"
radius={4}
/>
<Bar dataKey="road_crack" fill="var(--color-road_crack)" radius={4} />
<Bar
dataKey="damaged_road_marking"
fill="var(--color-damaged_road_marking)"
radius={4}
/>
<Bar
dataKey="drain_issue"
fill="var(--color-drain_issue)"
radius={4}
/>
<Bar
dataKey="defective_culvert"
fill="var(--color-defective_culvert)"
radius={4}
/>
</BarChart>
</ChartContainer>
</div>
);
}

View File

@@ -1,166 +0,0 @@
'use client';
import { useEffect } from 'react';
import {
MapContainer,
TileLayer,
CircleMarker,
Popup,
useMap,
} from 'react-leaflet';
import { LatLngBounds, LatLng } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Detection } from '@/types';
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
interface DashboardMapContentProps {
detections: Detection[];
}
// Component to auto-fit map bounds to show all markers
function FitBounds({ bounds }: { bounds: LatLngBounds }) {
const map = useMap();
useEffect(() => {
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [50, 50] });
}
}, [bounds, map]);
return null;
}
export default function DashboardMapContent({
detections,
}: DashboardMapContentProps) {
if (detections.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center bg-muted/20">
<p className="text-muted-foreground">No detections to display</p>
</div>
);
}
// Calculate bounds to fit all markers
const firstDetection = detections[0];
const bounds = new LatLngBounds(
new LatLng(firstDetection.latitude!, firstDetection.longitude!),
new LatLng(firstDetection.latitude!, firstDetection.longitude!),
);
detections.forEach((d) => {
if (d.latitude && d.longitude) {
bounds.extend(new LatLng(d.latitude, d.longitude));
}
});
// Center point
const center: [number, number] = [
(bounds.getNorth() + bounds.getSouth()) / 2,
(bounds.getEast() + bounds.getWest()) / 2,
];
// Get marker color based on detection type
const getMarkerColor = (typeId: string) => {
const config = DETECTION_TYPES[typeId.toLowerCase()];
if (config) {
return {
fill: config.color,
stroke: '#ffffff', // White stroke for better visibility on the map
};
}
return { fill: '#64748b', stroke: '#475569' }; // Default Slate
};
// Get display name for detection type
const getTypeName = (typeId: string) => {
const config = DETECTION_TYPES[typeId.toLowerCase()];
return config ? config.label : typeId.replace(/_/g, ' ');
};
return (
<MapContainer
center={center}
zoom={13}
className="h-full w-full"
scrollWheelZoom={true}
zoomAnimation={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Detection markers */}
{detections.map((detection, idx) => {
const colors = getMarkerColor(detection.type);
const typeName = getTypeName(detection.type);
return (
<CircleMarker
key={`${detection.id}-${idx}`}
center={[detection.latitude!, detection.longitude!]}
radius={10}
fillColor={colors.fill}
color={colors.stroke}
weight={2}
opacity={1}
fillOpacity={0.8}
>
<Popup>
<div className="text-sm min-w-[200px] py-1">
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none">
{typeName}
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Class</span>
<span className="font-medium text-right capitalize">
{detection.class.replace(/_/g, ' ')}
</span>
</div>
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">
Confidence
</span>
<span className="font-medium text-right">
{(detection.confidence * 100).toFixed(1)}%
</span>
</div>
</div>
<div className="pt-2 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
Location Details
</div>
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Latitude
</span>
<span className="font-medium tracking-tighter">
{detection.latitude!.toFixed(6)}
</span>
</div>
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Longitude
</span>
<span className="font-medium tracking-tighter">
{detection.longitude!.toFixed(6)}
</span>
</div>
</div>
</div>
</div>
</Popup>
</CircleMarker>
);
})}
{/* Auto-fit bounds */}
<FitBounds bounds={bounds} />
</MapContainer>
);
}

View File

@@ -1,216 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Milestone, Maximize2, Minimize2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Detection } from '@/types';
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
// Dynamically import the map to avoid SSR issues with Leaflet
const DashboardMapContent = dynamic(() => import('./dashboard-map-content'), {
ssr: false,
loading: () => (
<div className="h-full w-full flex items-center justify-center">
<Loader2 className="h-8 w-8 text-primary" />
</div>
),
});
interface DashboardMapProps {
className?: string;
selectedProjectId?: string | null;
selectedPackageId?: string | null;
selectedChainageId?: string | null;
projectSummary?: any;
}
export function DashboardMap({
className,
selectedProjectId,
selectedPackageId,
selectedChainageId,
projectSummary,
}: DashboardMapProps) {
const [detections, setDetections] = useState<Detection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isMaximized, setIsMaximized] = useState(false);
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
useEffect(() => {
if (!projectSummary) {
setDetections([]);
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setError(null);
const filteredDetections: Detection[] = [];
const packagesToProcess =
selectedPackageId && selectedPackageId !== 'all'
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
: projectSummary.packages || {};
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
if (!pkg) continue;
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 || []).filter(
(d: any) =>
(d.type || d.class || '').toLowerCase() !== 'good_sign_board',
);
filteredDetections.push(...chainageDetections);
}
}
setDetections(filteredDetections);
} catch (err) {
console.error('Failed to extract detections:', err);
setError('Failed to load detection data');
} finally {
setIsLoading(false);
}
}, [projectSummary, selectedPackageId, selectedChainageId]);
// Handle detection type toggle
const toggleType = (typeId: string) => {
setSelectedTypes((prev) => {
if (prev.includes(typeId)) {
const next = prev.filter((id) => id !== typeId);
return next.length === 0 ? [] : next;
} else {
return [...prev, typeId];
}
});
};
// Filter detections with valid GPS coordinates and selected types
const validDetections = detections.filter((d) => {
const hasGps = d.latitude && d.longitude;
if (!hasGps) return false;
if (selectedTypes.length === 0) return true;
const typeId = (d.type || d.class || '').toLowerCase();
return selectedTypes.includes(typeId);
});
return (
<Card
className={`overflow-hidden transition-all duration-300 p-2 rounded-lg ${
isMaximized
? 'fixed inset-0 z-100 m-0 rounded-none bg-background'
: className
}`}
>
<CardContent className="p-0 relative rounded-lg">
{/* Top Right Actions */}
<div className="absolute top-4 right-4 z-1000 pointer-events-none">
<div className="pointer-events-auto">
<Button
variant="default"
size="icon"
onClick={() => setIsMaximized(!isMaximized)}
title={isMaximized ? 'Exit Fullscreen' : 'Maximize Map'}
>
{isMaximized ? (
<Minimize2 className="h-5 w-5" />
) : (
<Maximize2 className="h-5 w-5" />
)}
</Button>
</div>
</div>
{/* Bottom Left Legend */}
<div className="absolute bottom-4 left-4 z-1000 pointer-events-none">
<div className="pointer-events-auto bg-zinc-950/80 backdrop-blur-xl border rounded-md p-2">
<div className="flex flex-wrap items-center gap-2 max-w-4xl">
{Object.values(DETECTION_TYPES).map((type) => {
const typeId = type.id.toLowerCase();
const count = detections.filter(
(d) =>
(d.type || d.class || '').toLowerCase() === typeId &&
d.latitude &&
d.longitude,
).length;
if (
count === 0 &&
(type.id.includes('culvert') || type.id === 'drain_issue')
)
return null;
if (type.id === 'good_sign_board') return null;
const isSelected =
selectedTypes.length === 0 || selectedTypes.includes(typeId);
return (
<button
key={type.id}
onClick={() => toggleType(typeId)}
className={`flex items-center gap-2 px-2 py-1.5 text-zinc-100 rounded-full transition-all text-xs ${
isSelected ? 'bg-zinc-800/80 ' : 'bg-transparent '
}`}
>
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: type.color }}
/>
<span>
{type.label} ({count})
</span>
</button>
);
})}
</div>
</div>
</div>
<div
className={`${isMaximized ? 'h-screen' : 'h-[600px]'} w-full relative transition-all`}
>
{isLoading ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
</div>
) : error ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<p className="text-muted-foreground">{error}</p>
</div>
) : validDetections.length === 0 ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<div className="text-center">
<p className="text-muted-foreground">
No detections with GPS coordinates found
</p>
<p className="text-sm text-muted-foreground mt-1 opacity-70">
Process some videos to see detections on the map
</p>
</div>
</div>
) : (
<DashboardMapContent detections={validDetections} />
)}
{/* Bottom Left Label Overlay */}
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,102 +0,0 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
export function DashboardSkeleton() {
return (
<div className="space-y-6">
{/* Stats Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{[...Array(6)].map((_, i) => (
<Card key={i} className="py-6 px-4">
<CardContent className="p-0 flex items-center justify-between gap-6">
<div className="flex flex-col gap-1 min-w-0 flex-1">
<Skeleton className="h-4 w-24 mb-1" />
<div className="flex flex-col gap-1">
<Skeleton className="h-9 w-20" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<Skeleton className="w-12 h-12 rounded-xl shrink-0" />
</CardContent>
</Card>
))}
</div>
{/* Charts Row Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Donut Chart Skeleton */}
<Card className="h-full">
<CardHeader className="items-center pb-2">
<Skeleton className="h-6 w-48 mb-2" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center">
<Skeleton className="h-56 w-56 rounded-full border-20 border-muted" />
</CardContent>
</Card>
{/* Bar Chart Skeleton */}
<Card className="h-full">
<CardHeader className="items-center pb-2">
<Skeleton className="h-6 w-40 mb-2" />
<Skeleton className="h-4 w-52" />
</CardHeader>
<CardContent className="h-[300px] flex items-end justify-between gap-2 px-6 pb-8 pt-4">
{[...Array(8)].map((_, i) => {
const heights = [
'60%',
'40%',
'75%',
'50%',
'65%',
'35%',
'80%',
'45%',
];
return (
<div
key={i}
className="flex flex-col gap-1 items-center flex-1"
>
<Skeleton
className="w-full rounded-t-sm"
style={{ height: heights[i % heights.length] }}
/>
<Skeleton className="h-2 w-full mt-2" />
</div>
);
})}
</CardContent>
</Card>
</div>
{/* Map Skeleton */}
<Card className="overflow-hidden">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Skeleton className="w-9 h-9 rounded-md" />
<div className="flex flex-col gap-1">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
<div className="flex gap-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center gap-1">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-2">
<Skeleton className="h-[500px] w-full rounded-md" />
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,176 +0,0 @@
'use client';
import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { Label, Pie, PieChart } from 'recharts';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
interface DetectionDonutChartProps {
defectedSignboard: number;
pothole: number;
roadCrack: number;
damagedRoadMarking: number;
// goodSignboard: number;
drainIssue: number;
defectiveCulvert: number;
isLoading?: boolean;
}
const chartConfig = {
pothole: {
label: DETECTION_TYPES.pothole.label + 's',
color: DETECTION_TYPES.pothole.color,
},
defectedSignboard: {
label: DETECTION_TYPES.defected_sign_board.label + 's',
color: DETECTION_TYPES.defected_sign_board.color,
},
roadCrack: {
label: DETECTION_TYPES.road_crack.label + 's',
color: DETECTION_TYPES.road_crack.color,
},
damagedRoadMarking: {
label: DETECTION_TYPES.damaged_road_marking.label + 's',
color: DETECTION_TYPES.damaged_road_marking.color,
},
drainIssue: {
label: DETECTION_TYPES.drain_issue.label + 's',
color: DETECTION_TYPES.drain_issue.color,
},
defectiveCulvert: {
label: DETECTION_TYPES.defective_culvert.label + 's',
color: DETECTION_TYPES.defective_culvert.color,
},
} satisfies ChartConfig;
export function DetectionDonutChart({
defectedSignboard,
pothole,
roadCrack,
damagedRoadMarking,
// goodSignboard,
drainIssue,
defectiveCulvert,
isLoading = false,
}: DetectionDonutChartProps) {
const chartData = React.useMemo(
() =>
[
{ type: 'pothole', count: pothole, fill: 'var(--color-pothole)' },
{
type: 'defectedSignboard',
count: defectedSignboard,
fill: 'var(--color-defectedSignboard)',
},
{ type: 'roadCrack', count: roadCrack, fill: 'var(--color-roadCrack)' },
{
type: 'damagedRoadMarking',
count: damagedRoadMarking,
fill: 'var(--color-damagedRoadMarking)',
},
/* {
type: 'goodSignboard',
count: goodSignboard,
fill: 'var(--color-goodSignboard)',
}, */
{
type: 'drainIssue',
count: drainIssue,
fill: 'var(--color-drainIssue)',
},
{
type: 'defectiveCulvert',
count: defectiveCulvert,
fill: 'var(--color-defectiveCulvert)',
},
].filter((item) => item.count > 0),
[
pothole,
defectedSignboard,
roadCrack,
damagedRoadMarking,
// goodSignboard,
drainIssue,
defectiveCulvert,
],
);
const totalDetections = React.useMemo(() => {
return chartData.reduce((acc, curr) => acc + curr.count, 0);
}, [chartData]);
if (isLoading) {
return (
<div className="h-[250px] flex items-center justify-center">
<Loader2 className="h-8 w-8 text-primary/50 animate-spin" />
</div>
);
}
if (totalDetections === 0) {
return (
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
<p className="text-sm">No detections found</p>
<p className="text-xs mt-1">Process videos to see data</p>
</div>
);
}
return (
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<PieChart>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={chartData}
dataKey="count"
nameKey="type"
innerRadius={60}
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-3xl font-bold"
>
{totalDetections.toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-muted-foreground"
>
Total
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
);
}

View File

@@ -1,131 +0,0 @@
'use client';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FolderOpen, Package, Milestone } from 'lucide-react';
import { Project } from '@/types';
interface FilterSelectorProps {
projects: Project[];
selectedProjectId: string | null;
selectedPackageId: string | null;
selectedChainageId: string | null;
onProjectChange: (projectId: string) => void;
onPackageChange: (packageId: string) => void;
onChainageChange: (chainageId: string) => void;
packages: Array<{ id: string; name: string }>;
chainages: Array<{ id: string; name: string }>;
isLoading?: boolean;
}
export function FilterSelector({
projects,
selectedProjectId,
selectedPackageId,
selectedChainageId,
onProjectChange,
onPackageChange,
onChainageChange,
packages,
chainages,
isLoading = false,
}: FilterSelectorProps) {
return (
<div className="flex flex-col gap-5 p-5">
{/* Project Dropdown */}
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
<FolderOpen className="h-4 w-4" />
</div>
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">
Project
</span>
</div>
<Select
value={selectedProjectId || ''}
onValueChange={onProjectChange}
disabled={isLoading || projects.length === 0}
>
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Package Dropdown */}
{selectedProjectId && (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
<Package className="h-4 w-4" />
</div>
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">
Package
</span>
</div>
<Select
value={selectedPackageId || 'all'}
onValueChange={onPackageChange}
disabled={isLoading}
>
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
<SelectValue placeholder="All packages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Packages</SelectItem>
{packages.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id}>
{pkg.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Chainage Dropdown */}
{selectedPackageId && selectedPackageId !== 'all' && (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
<Milestone className="h-4 w-4" />
</div>
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">
Segment
</span>
</div>
<Select
value={selectedChainageId || 'all'}
onValueChange={onChainageChange}
disabled={isLoading}
>
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
<SelectValue placeholder="All segments" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Segments</SelectItem>
{chainages.map((chn) => (
<SelectItem key={chn.id} value={chn.id}>
{chn.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
);
}

View File

@@ -1,57 +0,0 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
import { motion } from 'motion/react';
interface StatsCardProps {
title: string;
subtitle?: string;
value: number | string;
icon: LucideIcon;
isLoading?: boolean;
}
export function StatsCard({
title,
subtitle,
value,
icon: Icon,
isLoading = false,
}: StatsCardProps) {
return (
<motion.div
whileHover={{ y: -4, scale: 1.02 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
className="h-full"
>
<Card className="h-full py-6 px-4 transition-colors cursor-pointer">
<CardContent className="p-0 flex items-center justify-between gap-6">
<div className="flex flex-col gap-1 min-w-0">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
{title}
</h3>
<div className="flex flex-col">
{isLoading ? (
<div className="h-10 w-24 bg-muted rounded-md mt-2 animate-pulse" />
) : (
<>
<p className="text-3xl font-bold tracking-tight">{value}</p>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{subtitle}
</p>
)}
</>
)}
</div>
</div>
<div className="p-3 rounded-xl bg-primary/10 text-primary shrink-0 group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
<Icon className="h-6 w-6" />
</div>
</CardContent>
</Card>
</motion.div>
);
}

View File

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

View File

@@ -1,5 +1,4 @@
import {
LayoutDashboard,
Layers,
Milestone,
Package,
@@ -26,11 +25,6 @@ export type MenuItem = {
};
export const menuItems: MenuItem[] = [
{
title: 'Dashboard',
path: ROUTES.DASHBOARD,
icon: LayoutDashboard,
},
{
title: 'Upload',
path: ROUTES.UPLOAD,
@@ -120,3 +114,36 @@ export function filterMenuItems(
})
.filter((item): item is MenuItem => item !== null);
}
function findFirstMenuPath(items: MenuItem[]): string | undefined {
for (const item of items) {
if (item.path) return item.path;
const childPath = item.children
? findFirstMenuPath(item.children)
: undefined;
if (childPath) return childPath;
}
}
export function getFirstAccessibleMenuPath(
hasPermission: (permission?: string) => boolean,
fallback = ROUTES.ACCESS,
) {
return findFirstMenuPath(filterMenuItems(menuItems, hasPermission)) ?? fallback;
}
export function getFirstAccessiblePathForPermissions(
grantedPermissions: string[],
fallback = ROUTES.ACCESS,
) {
const normalizedPermissions = new Set(
grantedPermissions.map((permission) => permission.toLowerCase()),
);
return getFirstAccessibleMenuPath(
(permission) =>
!permission || normalizedPermissions.has(permission.toLowerCase()),
fallback,
);
}

View File

@@ -47,9 +47,6 @@ export const API_ROUTES = {
BASE: 'biz/api/v1/chainages',
DETAIL: (id: string) => `biz/api/v1/chainages/${id}`,
},
DASHBOARD: {
OVERVIEW: 'biz/api/v1/dashboard/overview',
},
VIDEOS: {
UPLOAD: '/biz/api/v1/upload',
MY_UPLOADS: '/biz/api/v1/videos/me',

View File

@@ -3,7 +3,6 @@
import { AppLoader } from '@/components/AppLoader';
import { useAppStore } from '@/store/app.store';
import { useAuthStore } from '@/store/auth.store';
import { ROUTES } from '@/utils/routes';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
@@ -13,12 +12,13 @@ export function GuestGuard({ children }: { children: ReactNode }) {
const accessToken = useAuthStore((state) => state.accessToken);
const isInitialized = useAppStore((state) => state.isInitialized);
const user = useAppStore((state) => state.user);
const defaultRoute = useAppStore((state) => state.defaultRoute);
useEffect(() => {
if (isInitialized && accessToken && user) {
router.replace(ROUTES.DASHBOARD);
router.replace(defaultRoute ?? '/access');
}
}, [accessToken, isInitialized, router, user]);
}, [accessToken, defaultRoute, isInitialized, router, user]);
if (!isInitialized) {
return (
@@ -27,7 +27,7 @@ export function GuestGuard({ children }: { children: ReactNode }) {
}
if (isInitialized && accessToken && user)
return <AppLoader label="Opening dashboard" />;
return <AppLoader label="Opening workspace" />;
return children;
}

View File

@@ -9,6 +9,8 @@ import {
} from '@/services/sse';
const DEFAULT_RETRY_DELAY_MS = 2500;
const DEFAULT_MAX_RETRY_DELAY_MS = 30000;
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 6;
const EMPTY_RECONNECT_EVENTS: ReadonlyArray<PropertyKey> = [];
interface SseToken {
@@ -21,7 +23,10 @@ interface UseSseWithTokenOptions<TEvents extends Record<string, unknown>> {
getPath: (token: string) => string;
events: SseEventMap<TEvents>;
retryDelayMs?: number;
maxRetryDelayMs?: number;
maxReconnectAttempts?: number;
onConnectionError?: () => void;
onConnectionOpen?: () => void;
reconnectOnEvents?: ReadonlyArray<keyof TEvents>;
shouldStop?: (
eventName: keyof TEvents,
@@ -35,12 +40,16 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
getPath,
events,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
maxRetryDelayMs = DEFAULT_MAX_RETRY_DELAY_MS,
maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS,
onConnectionError,
onConnectionOpen,
reconnectOnEvents = EMPTY_RECONNECT_EVENTS as ReadonlyArray<keyof TEvents>,
shouldStop,
}: UseSseWithTokenOptions<TEvents>) {
const connectionRef = useRef<SseConnection | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttemptRef = useRef(0);
const stoppedRef = useRef(false);
const clearRetryTimer = useCallback(() => {
@@ -56,6 +65,7 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
const stop = useCallback(() => {
stoppedRef.current = true;
reconnectAttemptRef.current = 0;
clearRetryTimer();
closeConnection();
}, [clearRetryTimer, closeConnection]);
@@ -64,16 +74,33 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
if (!enabled) return;
stoppedRef.current = false;
reconnectAttemptRef.current = 0;
const getReconnectDelay = () =>
Math.min(
retryDelayMs * 2 ** Math.max(reconnectAttemptRef.current - 1, 0),
maxRetryDelayMs,
);
const scheduleReconnect = (notify = true) => {
if (stoppedRef.current || retryTimerRef.current) return;
closeConnection();
if (notify) onConnectionError?.();
if (notify) {
onConnectionError?.();
}
if (reconnectAttemptRef.current >= maxReconnectAttempts) {
stoppedRef.current = true;
return;
}
reconnectAttemptRef.current += 1;
retryTimerRef.current = setTimeout(() => {
retryTimerRef.current = null;
connect();
}, retryDelayMs);
}, getReconnectDelay());
};
const connect = () => {
@@ -116,6 +143,10 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
wrappedEvents,
);
connection.source.onopen = () => {
reconnectAttemptRef.current = 0;
onConnectionOpen?.();
};
connection.source.onerror = () => scheduleReconnect();
connectionRef.current = connection;
})
@@ -131,7 +162,10 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
events,
getPath,
getToken,
maxReconnectAttempts,
maxRetryDelayMs,
onConnectionError,
onConnectionOpen,
reconnectOnEvents,
retryDelayMs,
shouldStop,

View File

@@ -1,15 +1,11 @@
import { authService } from '@/services/api/auth.service';
import { initializeAuthenticatedApp } from '@/services/initializer.service';
import { useAppStore } from '@/store/app.store';
import { useAuthStore } from '@/store/auth.store';
import type { LoginPayload } from '@/types';
import { ROUTES } from '@/utils/routes';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
export const useLoginForm = () => {
const router = useRouter();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const setLoading = useAppStore((state) => state.setLoading);
const {
@@ -39,10 +35,8 @@ export const useLoginForm = () => {
setAccessToken(accessToken);
setLoading();
await initializeAuthenticatedApp();
toast.success('Login successful');
router.replace(ROUTES.DASHBOARD);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
}

View File

@@ -1,62 +0,0 @@
import axiosClient from '../axios/axios';
import { API_ROUTES } from '@/constants/apiRoutes';
import { Detection } from '@/types';
import { projectService } from './project.service';
/**
* Detection Service
*/
export const detectionService = {
/**
* Fetch all detections from completed videos
* Uses the summary endpoint to get detections for each project
*/
getAllDetections: async (): Promise<Detection[]> => {
try {
// First get all projects
const projectsResponse = await projectService.getProjects();
const projects = projectsResponse.items;
// Then fetch detections for each project
const allDetections: Detection[] = [];
for (const project of projects) {
try {
const response = await axiosClient.get<{
packages: {
[key: string]: {
chainages: {
[key: string]: {
detections: Detection[];
};
};
};
};
}>(API_ROUTES.DASHBOARD.OVERVIEW, {
params: { project_id: project.id },
});
const summary = response.data;
// Extract detections from the nested structure
for (const pkg of Object.values(summary.packages || {})) {
for (const loc of Object.values(pkg.chainages || {})) {
allDetections.push(...(loc.detections || []));
}
}
} catch (e) {
// Skip projects that fail to load
console.warn(
`Failed to load detections for project ${project.id}:`,
e,
);
}
}
return allDetections;
} catch (e) {
console.error('Failed to fetch all detections:', e);
return [];
}
},
};

View File

@@ -1,10 +1,8 @@
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 './permission.service';
export * from './role.service';
export * from './user.service';

View File

@@ -1,52 +0,0 @@
import { API_ROUTES } from '@/constants/apiRoutes';
import axiosClient from '../axios/axios';
export const projectSummaryService = {
getProjectSummary: async <T = unknown>(projectId: string): Promise<T> => {
const response = await axiosClient.get<T>(API_ROUTES.DASHBOARD.OVERVIEW, {
params: { project_id: projectId },
});
return response.data;
},
getProjectSummaryByVideo: async <T = unknown>(
projectId: string,
videoId: string,
): Promise<T> => {
const response = await axiosClient.get<T>(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;
},
};

View File

@@ -1,16 +1,34 @@
import { getFirstAccessiblePathForPermissions } from '@/config/menu.config';
import { authService } from '@/services/api/auth.service';
import { useAppStore } from '@/store/app.store';
import type { UserProfile } from '@/types';
let authenticatedAppInitialization: Promise<void> | null = null;
export async function initializeAuthenticatedApp() {
if (authenticatedAppInitialization) {
return authenticatedAppInitialization;
}
authenticatedAppInitialization = loadAuthenticatedApp().finally(() => {
authenticatedAppInitialization = null;
});
return authenticatedAppInitialization;
}
async function loadAuthenticatedApp() {
const [{ user, tenant }, permissionsResponse] = await Promise.all([
authService.me(),
authService.permissions(),
]);
const permissions = permissionsResponse.granted_permissions ?? [];
useAppStore.getState().setUserContext({
user: user as UserProfile,
tenant,
permissions: permissionsResponse.granted_permissions ?? [],
permissions,
defaultRoute: getFirstAccessiblePathForPermissions(permissions),
});
}

View File

@@ -7,6 +7,7 @@ interface AppState {
user: UserProfile | null;
tenant: TenantInfo | null;
permissions: string[];
defaultRoute: string | null;
loadStatus: AppLoadStatus;
isInitialized: boolean;
setLoading: () => void;
@@ -16,6 +17,7 @@ interface AppState {
user: UserProfile;
tenant?: TenantInfo | null;
permissions?: string[];
defaultRoute?: string | null;
}) => void;
clear: () => void;
}
@@ -24,16 +26,23 @@ export const useAppStore = create<AppState>()((set) => ({
user: null,
tenant: null,
permissions: [],
defaultRoute: null,
loadStatus: 'idle',
isInitialized: false,
setLoading: () => set({ loadStatus: 'loading', isInitialized: false }),
setLoaded: () => set({ loadStatus: 'loaded', isInitialized: true }),
setLoadError: () => set({ loadStatus: 'error', isInitialized: true }),
setUserContext: ({ user, tenant = null, permissions = [] }) =>
setUserContext: ({
user,
tenant = null,
permissions = [],
defaultRoute = null,
}) =>
set({
user,
tenant,
permissions,
defaultRoute,
loadStatus: 'loaded',
isInitialized: true,
}),
@@ -42,6 +51,7 @@ export const useAppStore = create<AppState>()((set) => ({
user: null,
tenant: null,
permissions: [],
defaultRoute: null,
loadStatus: 'idle',
isInitialized: false,
}),

View File

@@ -3,7 +3,6 @@ export const ROUTES = {
FORGOT_PASSWORD: '/forgot-password',
RESET_PASSWORD: '/reset-password',
SET_PASSWORD: '/set-password',
DASHBOARD: '/dashboard',
PROJECT: '/project',
PACKAGE: '/package',
CHAINAGE: '/segment',