refactor: remove static routes and stop multiple token api call in sse
This commit is contained in:
51
package-lock.json
generated
51
package-lock.json
generated
@@ -26,7 +26,6 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"libphonenumber-js": "^1.13.7",
|
"libphonenumber-js": "^1.13.7",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -45,7 +43,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -2882,17 +2879,6 @@
|
|||||||
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3380,13 +3366,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3401,16 +3380,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
@@ -6851,12 +6820,6 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -8024,20 +7987,6 @@
|
|||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"libphonenumber-js": "^1.13.7",
|
"libphonenumber-js": "^1.13.7",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
@@ -40,7 +39,6 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -49,7 +47,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { redirect } from 'next/navigation';
|
'use client';
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { AppLoader } from '@/components/AppLoader';
|
||||||
|
import { useAppStore } from '@/store/app.store';
|
||||||
|
|
||||||
export default function HomePage() {
|
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" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
@@ -125,6 +125,7 @@ function mergeTicketListItem(
|
|||||||
|
|
||||||
export function useTenantTicketTableEvents(enabled = true) {
|
export function useTenantTicketTableEvents(enabled = true) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const hasInvalidatedAfterConnectionErrorRef = useRef(false);
|
||||||
|
|
||||||
const events = useMemo(
|
const events = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -154,14 +155,22 @@ export function useTenantTicketTableEvents(enabled = true) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onConnectionError = useCallback(() => {
|
const onConnectionError = useCallback(() => {
|
||||||
|
if (hasInvalidatedAfterConnectionErrorRef.current) return;
|
||||||
|
|
||||||
|
hasInvalidatedAfterConnectionErrorRef.current = true;
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const onConnectionOpen = useCallback(() => {
|
||||||
|
hasInvalidatedAfterConnectionErrorRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useSseWithToken({
|
useSseWithToken({
|
||||||
enabled,
|
enabled,
|
||||||
getToken: ticketService.createTenantTicketEventsToken,
|
getToken: ticketService.createTenantTicketEventsToken,
|
||||||
getPath,
|
getPath,
|
||||||
events,
|
events,
|
||||||
onConnectionError,
|
onConnectionError,
|
||||||
|
onConnectionOpen,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -13,7 +12,7 @@ export default function NotFound() {
|
|||||||
The page you are looking for does not exist or may have been moved.
|
The page you are looking for does not exist or may have been moved.
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="mt-6">
|
<Button asChild className="mt-6">
|
||||||
<Link href={ROUTES.DASHBOARD}>Go to dashboard</Link>
|
<Link href="/">Go to workspace</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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='© <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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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='© <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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
|
||||||
Layers,
|
Layers,
|
||||||
Milestone,
|
Milestone,
|
||||||
Package,
|
Package,
|
||||||
@@ -26,11 +25,6 @@ export type MenuItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const menuItems: MenuItem[] = [
|
export const menuItems: MenuItem[] = [
|
||||||
{
|
|
||||||
title: 'Dashboard',
|
|
||||||
path: ROUTES.DASHBOARD,
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Upload',
|
title: 'Upload',
|
||||||
path: ROUTES.UPLOAD,
|
path: ROUTES.UPLOAD,
|
||||||
@@ -120,3 +114,36 @@ export function filterMenuItems(
|
|||||||
})
|
})
|
||||||
.filter((item): item is MenuItem => item !== null);
|
.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ export const API_ROUTES = {
|
|||||||
BASE: 'biz/api/v1/chainages',
|
BASE: 'biz/api/v1/chainages',
|
||||||
DETAIL: (id: string) => `biz/api/v1/chainages/${id}`,
|
DETAIL: (id: string) => `biz/api/v1/chainages/${id}`,
|
||||||
},
|
},
|
||||||
DASHBOARD: {
|
|
||||||
OVERVIEW: 'biz/api/v1/dashboard/overview',
|
|
||||||
},
|
|
||||||
VIDEOS: {
|
VIDEOS: {
|
||||||
UPLOAD: '/biz/api/v1/upload',
|
UPLOAD: '/biz/api/v1/upload',
|
||||||
MY_UPLOADS: '/biz/api/v1/videos/me',
|
MY_UPLOADS: '/biz/api/v1/videos/me',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { AppLoader } from '@/components/AppLoader';
|
import { AppLoader } from '@/components/AppLoader';
|
||||||
import { useAppStore } from '@/store/app.store';
|
import { useAppStore } from '@/store/app.store';
|
||||||
import { useAuthStore } from '@/store/auth.store';
|
import { useAuthStore } from '@/store/auth.store';
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -13,12 +12,13 @@ export function GuestGuard({ children }: { children: ReactNode }) {
|
|||||||
const accessToken = useAuthStore((state) => state.accessToken);
|
const accessToken = useAuthStore((state) => state.accessToken);
|
||||||
const isInitialized = useAppStore((state) => state.isInitialized);
|
const isInitialized = useAppStore((state) => state.isInitialized);
|
||||||
const user = useAppStore((state) => state.user);
|
const user = useAppStore((state) => state.user);
|
||||||
|
const defaultRoute = useAppStore((state) => state.defaultRoute);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialized && accessToken && user) {
|
if (isInitialized && accessToken && user) {
|
||||||
router.replace(ROUTES.DASHBOARD);
|
router.replace(defaultRoute ?? '/access');
|
||||||
}
|
}
|
||||||
}, [accessToken, isInitialized, router, user]);
|
}, [accessToken, defaultRoute, isInitialized, router, user]);
|
||||||
|
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +27,7 @@ export function GuestGuard({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isInitialized && accessToken && user)
|
if (isInitialized && accessToken && user)
|
||||||
return <AppLoader label="Opening dashboard" />;
|
return <AppLoader label="Opening workspace" />;
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from '@/services/sse';
|
} from '@/services/sse';
|
||||||
|
|
||||||
const DEFAULT_RETRY_DELAY_MS = 2500;
|
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> = [];
|
const EMPTY_RECONNECT_EVENTS: ReadonlyArray<PropertyKey> = [];
|
||||||
|
|
||||||
interface SseToken {
|
interface SseToken {
|
||||||
@@ -21,7 +23,10 @@ interface UseSseWithTokenOptions<TEvents extends Record<string, unknown>> {
|
|||||||
getPath: (token: string) => string;
|
getPath: (token: string) => string;
|
||||||
events: SseEventMap<TEvents>;
|
events: SseEventMap<TEvents>;
|
||||||
retryDelayMs?: number;
|
retryDelayMs?: number;
|
||||||
|
maxRetryDelayMs?: number;
|
||||||
|
maxReconnectAttempts?: number;
|
||||||
onConnectionError?: () => void;
|
onConnectionError?: () => void;
|
||||||
|
onConnectionOpen?: () => void;
|
||||||
reconnectOnEvents?: ReadonlyArray<keyof TEvents>;
|
reconnectOnEvents?: ReadonlyArray<keyof TEvents>;
|
||||||
shouldStop?: (
|
shouldStop?: (
|
||||||
eventName: keyof TEvents,
|
eventName: keyof TEvents,
|
||||||
@@ -35,12 +40,16 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
|||||||
getPath,
|
getPath,
|
||||||
events,
|
events,
|
||||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||||
|
maxRetryDelayMs = DEFAULT_MAX_RETRY_DELAY_MS,
|
||||||
|
maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS,
|
||||||
onConnectionError,
|
onConnectionError,
|
||||||
|
onConnectionOpen,
|
||||||
reconnectOnEvents = EMPTY_RECONNECT_EVENTS as ReadonlyArray<keyof TEvents>,
|
reconnectOnEvents = EMPTY_RECONNECT_EVENTS as ReadonlyArray<keyof TEvents>,
|
||||||
shouldStop,
|
shouldStop,
|
||||||
}: UseSseWithTokenOptions<TEvents>) {
|
}: UseSseWithTokenOptions<TEvents>) {
|
||||||
const connectionRef = useRef<SseConnection | null>(null);
|
const connectionRef = useRef<SseConnection | null>(null);
|
||||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectAttemptRef = useRef(0);
|
||||||
const stoppedRef = useRef(false);
|
const stoppedRef = useRef(false);
|
||||||
|
|
||||||
const clearRetryTimer = useCallback(() => {
|
const clearRetryTimer = useCallback(() => {
|
||||||
@@ -56,6 +65,7 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
|||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
stoppedRef.current = true;
|
stoppedRef.current = true;
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
closeConnection();
|
closeConnection();
|
||||||
}, [clearRetryTimer, closeConnection]);
|
}, [clearRetryTimer, closeConnection]);
|
||||||
@@ -64,16 +74,33 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
|||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
stoppedRef.current = false;
|
stoppedRef.current = false;
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
|
||||||
|
const getReconnectDelay = () =>
|
||||||
|
Math.min(
|
||||||
|
retryDelayMs * 2 ** Math.max(reconnectAttemptRef.current - 1, 0),
|
||||||
|
maxRetryDelayMs,
|
||||||
|
);
|
||||||
|
|
||||||
const scheduleReconnect = (notify = true) => {
|
const scheduleReconnect = (notify = true) => {
|
||||||
if (stoppedRef.current || retryTimerRef.current) return;
|
if (stoppedRef.current || retryTimerRef.current) return;
|
||||||
|
|
||||||
closeConnection();
|
closeConnection();
|
||||||
if (notify) onConnectionError?.();
|
|
||||||
|
if (notify) {
|
||||||
|
onConnectionError?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectAttemptRef.current >= maxReconnectAttempts) {
|
||||||
|
stoppedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttemptRef.current += 1;
|
||||||
retryTimerRef.current = setTimeout(() => {
|
retryTimerRef.current = setTimeout(() => {
|
||||||
retryTimerRef.current = null;
|
retryTimerRef.current = null;
|
||||||
connect();
|
connect();
|
||||||
}, retryDelayMs);
|
}, getReconnectDelay());
|
||||||
};
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
@@ -116,6 +143,10 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
|||||||
wrappedEvents,
|
wrappedEvents,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
connection.source.onopen = () => {
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
onConnectionOpen?.();
|
||||||
|
};
|
||||||
connection.source.onerror = () => scheduleReconnect();
|
connection.source.onerror = () => scheduleReconnect();
|
||||||
connectionRef.current = connection;
|
connectionRef.current = connection;
|
||||||
})
|
})
|
||||||
@@ -131,7 +162,10 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
|||||||
events,
|
events,
|
||||||
getPath,
|
getPath,
|
||||||
getToken,
|
getToken,
|
||||||
|
maxReconnectAttempts,
|
||||||
|
maxRetryDelayMs,
|
||||||
onConnectionError,
|
onConnectionError,
|
||||||
|
onConnectionOpen,
|
||||||
reconnectOnEvents,
|
reconnectOnEvents,
|
||||||
retryDelayMs,
|
retryDelayMs,
|
||||||
shouldStop,
|
shouldStop,
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { authService } from '@/services/api/auth.service';
|
import { authService } from '@/services/api/auth.service';
|
||||||
import { initializeAuthenticatedApp } from '@/services/initializer.service';
|
|
||||||
import { useAppStore } from '@/store/app.store';
|
import { useAppStore } from '@/store/app.store';
|
||||||
import { useAuthStore } from '@/store/auth.store';
|
import { useAuthStore } from '@/store/auth.store';
|
||||||
import type { LoginPayload } from '@/types';
|
import type { LoginPayload } from '@/types';
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useLoginForm = () => {
|
export const useLoginForm = () => {
|
||||||
const router = useRouter();
|
|
||||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||||
const setLoading = useAppStore((state) => state.setLoading);
|
const setLoading = useAppStore((state) => state.setLoading);
|
||||||
const {
|
const {
|
||||||
@@ -39,10 +35,8 @@ export const useLoginForm = () => {
|
|||||||
|
|
||||||
setAccessToken(accessToken);
|
setAccessToken(accessToken);
|
||||||
setLoading();
|
setLoading();
|
||||||
await initializeAuthenticatedApp();
|
|
||||||
|
|
||||||
toast.success('Login successful');
|
toast.success('Login successful');
|
||||||
router.replace(ROUTES.DASHBOARD);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
|
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
export * from './project.service';
|
export * from './project.service';
|
||||||
export * from './project-summary.service';
|
|
||||||
export * from './package.service';
|
export * from './package.service';
|
||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
export { chainageService } from './chainage.service';
|
export { chainageService } from './chainage.service';
|
||||||
export * from './video.service';
|
export * from './video.service';
|
||||||
export * from './detection.service';
|
|
||||||
export * from './permission.service';
|
export * from './permission.service';
|
||||||
export * from './role.service';
|
export * from './role.service';
|
||||||
export * from './user.service';
|
export * from './user.service';
|
||||||
|
|||||||
@@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,16 +1,34 @@
|
|||||||
|
import { getFirstAccessiblePathForPermissions } from '@/config/menu.config';
|
||||||
import { authService } from '@/services/api/auth.service';
|
import { authService } from '@/services/api/auth.service';
|
||||||
import { useAppStore } from '@/store/app.store';
|
import { useAppStore } from '@/store/app.store';
|
||||||
import type { UserProfile } from '@/types';
|
import type { UserProfile } from '@/types';
|
||||||
|
|
||||||
|
let authenticatedAppInitialization: Promise<void> | null = null;
|
||||||
|
|
||||||
export async function initializeAuthenticatedApp() {
|
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([
|
const [{ user, tenant }, permissionsResponse] = await Promise.all([
|
||||||
authService.me(),
|
authService.me(),
|
||||||
authService.permissions(),
|
authService.permissions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const permissions = permissionsResponse.granted_permissions ?? [];
|
||||||
|
|
||||||
useAppStore.getState().setUserContext({
|
useAppStore.getState().setUserContext({
|
||||||
user: user as UserProfile,
|
user: user as UserProfile,
|
||||||
tenant,
|
tenant,
|
||||||
permissions: permissionsResponse.granted_permissions ?? [],
|
permissions,
|
||||||
|
defaultRoute: getFirstAccessiblePathForPermissions(permissions),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface AppState {
|
|||||||
user: UserProfile | null;
|
user: UserProfile | null;
|
||||||
tenant: TenantInfo | null;
|
tenant: TenantInfo | null;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
defaultRoute: string | null;
|
||||||
loadStatus: AppLoadStatus;
|
loadStatus: AppLoadStatus;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
setLoading: () => void;
|
setLoading: () => void;
|
||||||
@@ -16,6 +17,7 @@ interface AppState {
|
|||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
tenant?: TenantInfo | null;
|
tenant?: TenantInfo | null;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
|
defaultRoute?: string | null;
|
||||||
}) => void;
|
}) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
@@ -24,16 +26,23 @@ export const useAppStore = create<AppState>()((set) => ({
|
|||||||
user: null,
|
user: null,
|
||||||
tenant: null,
|
tenant: null,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
defaultRoute: null,
|
||||||
loadStatus: 'idle',
|
loadStatus: 'idle',
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
setLoading: () => set({ loadStatus: 'loading', isInitialized: false }),
|
setLoading: () => set({ loadStatus: 'loading', isInitialized: false }),
|
||||||
setLoaded: () => set({ loadStatus: 'loaded', isInitialized: true }),
|
setLoaded: () => set({ loadStatus: 'loaded', isInitialized: true }),
|
||||||
setLoadError: () => set({ loadStatus: 'error', isInitialized: true }),
|
setLoadError: () => set({ loadStatus: 'error', isInitialized: true }),
|
||||||
setUserContext: ({ user, tenant = null, permissions = [] }) =>
|
setUserContext: ({
|
||||||
|
user,
|
||||||
|
tenant = null,
|
||||||
|
permissions = [],
|
||||||
|
defaultRoute = null,
|
||||||
|
}) =>
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
tenant,
|
tenant,
|
||||||
permissions,
|
permissions,
|
||||||
|
defaultRoute,
|
||||||
loadStatus: 'loaded',
|
loadStatus: 'loaded',
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
}),
|
}),
|
||||||
@@ -42,6 +51,7 @@ export const useAppStore = create<AppState>()((set) => ({
|
|||||||
user: null,
|
user: null,
|
||||||
tenant: null,
|
tenant: null,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
defaultRoute: null,
|
||||||
loadStatus: 'idle',
|
loadStatus: 'idle',
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export const ROUTES = {
|
|||||||
FORGOT_PASSWORD: '/forgot-password',
|
FORGOT_PASSWORD: '/forgot-password',
|
||||||
RESET_PASSWORD: '/reset-password',
|
RESET_PASSWORD: '/reset-password',
|
||||||
SET_PASSWORD: '/set-password',
|
SET_PASSWORD: '/set-password',
|
||||||
DASHBOARD: '/dashboard',
|
|
||||||
PROJECT: '/project',
|
PROJECT: '/project',
|
||||||
PACKAGE: '/package',
|
PACKAGE: '/package',
|
||||||
CHAINAGE: '/segment',
|
CHAINAGE: '/segment',
|
||||||
|
|||||||
Reference in New Issue
Block a user