refactor: remove static routes and stop multiple token api call in sse
This commit is contained in:
@@ -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';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AppLoader } from '@/components/AppLoader';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
|
||||
export default function HomePage() {
|
||||
redirect(ROUTES.DASHBOARD);
|
||||
const router = useRouter();
|
||||
const defaultRoute = useAppStore((state) => state.defaultRoute);
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(defaultRoute ?? '/access');
|
||||
}, [defaultRoute, router]);
|
||||
|
||||
return <AppLoader label="Opening workspace" />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||
@@ -125,6 +125,7 @@ function mergeTicketListItem(
|
||||
|
||||
export function useTenantTicketTableEvents(enabled = true) {
|
||||
const queryClient = useQueryClient();
|
||||
const hasInvalidatedAfterConnectionErrorRef = useRef(false);
|
||||
|
||||
const events = useMemo(
|
||||
() => ({
|
||||
@@ -154,14 +155,22 @@ export function useTenantTicketTableEvents(enabled = true) {
|
||||
);
|
||||
|
||||
const onConnectionError = useCallback(() => {
|
||||
if (hasInvalidatedAfterConnectionErrorRef.current) return;
|
||||
|
||||
hasInvalidatedAfterConnectionErrorRef.current = true;
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
}, [queryClient]);
|
||||
|
||||
const onConnectionOpen = useCallback(() => {
|
||||
hasInvalidatedAfterConnectionErrorRef.current = false;
|
||||
}, []);
|
||||
|
||||
useSseWithToken({
|
||||
enabled,
|
||||
getToken: ticketService.createTenantTicketEventsToken,
|
||||
getPath,
|
||||
events,
|
||||
onConnectionError,
|
||||
onConnectionOpen,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
@@ -13,7 +12,7 @@ export default function NotFound() {
|
||||
The page you are looking for does not exist or may have been moved.
|
||||
</p>
|
||||
<Button asChild className="mt-6">
|
||||
<Link href={ROUTES.DASHBOARD}>Go to dashboard</Link>
|
||||
<Link href="/">Go to workspace</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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 {
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
Milestone,
|
||||
Package,
|
||||
@@ -26,11 +25,6 @@ export type MenuItem = {
|
||||
};
|
||||
|
||||
export const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: ROUTES.DASHBOARD,
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'Upload',
|
||||
path: ROUTES.UPLOAD,
|
||||
@@ -120,3 +114,36 @@ export function filterMenuItems(
|
||||
})
|
||||
.filter((item): item is MenuItem => item !== null);
|
||||
}
|
||||
|
||||
function findFirstMenuPath(items: MenuItem[]): string | undefined {
|
||||
for (const item of items) {
|
||||
if (item.path) return item.path;
|
||||
|
||||
const childPath = item.children
|
||||
? findFirstMenuPath(item.children)
|
||||
: undefined;
|
||||
if (childPath) return childPath;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFirstAccessibleMenuPath(
|
||||
hasPermission: (permission?: string) => boolean,
|
||||
fallback = ROUTES.ACCESS,
|
||||
) {
|
||||
return findFirstMenuPath(filterMenuItems(menuItems, hasPermission)) ?? fallback;
|
||||
}
|
||||
|
||||
export function getFirstAccessiblePathForPermissions(
|
||||
grantedPermissions: string[],
|
||||
fallback = ROUTES.ACCESS,
|
||||
) {
|
||||
const normalizedPermissions = new Set(
|
||||
grantedPermissions.map((permission) => permission.toLowerCase()),
|
||||
);
|
||||
|
||||
return getFirstAccessibleMenuPath(
|
||||
(permission) =>
|
||||
!permission || normalizedPermissions.has(permission.toLowerCase()),
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,9 +47,6 @@ export const API_ROUTES = {
|
||||
BASE: 'biz/api/v1/chainages',
|
||||
DETAIL: (id: string) => `biz/api/v1/chainages/${id}`,
|
||||
},
|
||||
DASHBOARD: {
|
||||
OVERVIEW: 'biz/api/v1/dashboard/overview',
|
||||
},
|
||||
VIDEOS: {
|
||||
UPLOAD: '/biz/api/v1/upload',
|
||||
MY_UPLOADS: '/biz/api/v1/videos/me',
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { AppLoader } from '@/components/AppLoader';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -13,12 +12,13 @@ export function GuestGuard({ children }: { children: ReactNode }) {
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const isInitialized = useAppStore((state) => state.isInitialized);
|
||||
const user = useAppStore((state) => state.user);
|
||||
const defaultRoute = useAppStore((state) => state.defaultRoute);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized && accessToken && user) {
|
||||
router.replace(ROUTES.DASHBOARD);
|
||||
router.replace(defaultRoute ?? '/access');
|
||||
}
|
||||
}, [accessToken, isInitialized, router, user]);
|
||||
}, [accessToken, defaultRoute, isInitialized, router, user]);
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
@@ -27,7 +27,7 @@ export function GuestGuard({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
if (isInitialized && accessToken && user)
|
||||
return <AppLoader label="Opening dashboard" />;
|
||||
return <AppLoader label="Opening workspace" />;
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '@/services/sse';
|
||||
|
||||
const DEFAULT_RETRY_DELAY_MS = 2500;
|
||||
const DEFAULT_MAX_RETRY_DELAY_MS = 30000;
|
||||
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 6;
|
||||
const EMPTY_RECONNECT_EVENTS: ReadonlyArray<PropertyKey> = [];
|
||||
|
||||
interface SseToken {
|
||||
@@ -21,7 +23,10 @@ interface UseSseWithTokenOptions<TEvents extends Record<string, unknown>> {
|
||||
getPath: (token: string) => string;
|
||||
events: SseEventMap<TEvents>;
|
||||
retryDelayMs?: number;
|
||||
maxRetryDelayMs?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
onConnectionError?: () => void;
|
||||
onConnectionOpen?: () => void;
|
||||
reconnectOnEvents?: ReadonlyArray<keyof TEvents>;
|
||||
shouldStop?: (
|
||||
eventName: keyof TEvents,
|
||||
@@ -35,12 +40,16 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
||||
getPath,
|
||||
events,
|
||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||
maxRetryDelayMs = DEFAULT_MAX_RETRY_DELAY_MS,
|
||||
maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS,
|
||||
onConnectionError,
|
||||
onConnectionOpen,
|
||||
reconnectOnEvents = EMPTY_RECONNECT_EVENTS as ReadonlyArray<keyof TEvents>,
|
||||
shouldStop,
|
||||
}: UseSseWithTokenOptions<TEvents>) {
|
||||
const connectionRef = useRef<SseConnection | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const stoppedRef = useRef(false);
|
||||
|
||||
const clearRetryTimer = useCallback(() => {
|
||||
@@ -56,6 +65,7 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
||||
|
||||
const stop = useCallback(() => {
|
||||
stoppedRef.current = true;
|
||||
reconnectAttemptRef.current = 0;
|
||||
clearRetryTimer();
|
||||
closeConnection();
|
||||
}, [clearRetryTimer, closeConnection]);
|
||||
@@ -64,16 +74,33 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
||||
if (!enabled) return;
|
||||
|
||||
stoppedRef.current = false;
|
||||
reconnectAttemptRef.current = 0;
|
||||
|
||||
const getReconnectDelay = () =>
|
||||
Math.min(
|
||||
retryDelayMs * 2 ** Math.max(reconnectAttemptRef.current - 1, 0),
|
||||
maxRetryDelayMs,
|
||||
);
|
||||
|
||||
const scheduleReconnect = (notify = true) => {
|
||||
if (stoppedRef.current || retryTimerRef.current) return;
|
||||
|
||||
closeConnection();
|
||||
if (notify) onConnectionError?.();
|
||||
|
||||
if (notify) {
|
||||
onConnectionError?.();
|
||||
}
|
||||
|
||||
if (reconnectAttemptRef.current >= maxReconnectAttempts) {
|
||||
stoppedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttemptRef.current += 1;
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
retryTimerRef.current = null;
|
||||
connect();
|
||||
}, retryDelayMs);
|
||||
}, getReconnectDelay());
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
@@ -116,6 +143,10 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
||||
wrappedEvents,
|
||||
);
|
||||
|
||||
connection.source.onopen = () => {
|
||||
reconnectAttemptRef.current = 0;
|
||||
onConnectionOpen?.();
|
||||
};
|
||||
connection.source.onerror = () => scheduleReconnect();
|
||||
connectionRef.current = connection;
|
||||
})
|
||||
@@ -131,7 +162,10 @@ export function useSseWithToken<TEvents extends Record<string, unknown>>({
|
||||
events,
|
||||
getPath,
|
||||
getToken,
|
||||
maxReconnectAttempts,
|
||||
maxRetryDelayMs,
|
||||
onConnectionError,
|
||||
onConnectionOpen,
|
||||
reconnectOnEvents,
|
||||
retryDelayMs,
|
||||
shouldStop,
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { authService } from '@/services/api/auth.service';
|
||||
import { initializeAuthenticatedApp } from '@/services/initializer.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import type { LoginPayload } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useLoginForm = () => {
|
||||
const router = useRouter();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
const setLoading = useAppStore((state) => state.setLoading);
|
||||
const {
|
||||
@@ -39,10 +35,8 @@ export const useLoginForm = () => {
|
||||
|
||||
setAccessToken(accessToken);
|
||||
setLoading();
|
||||
await initializeAuthenticatedApp();
|
||||
|
||||
toast.success('Login successful');
|
||||
router.replace(ROUTES.DASHBOARD);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
|
||||
}
|
||||
|
||||
@@ -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-summary.service';
|
||||
export * from './package.service';
|
||||
export * from './auth.service';
|
||||
export { chainageService } from './chainage.service';
|
||||
export * from './video.service';
|
||||
export * from './detection.service';
|
||||
export * from './permission.service';
|
||||
export * from './role.service';
|
||||
export * from './user.service';
|
||||
|
||||
@@ -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 { useAppStore } from '@/store/app.store';
|
||||
import type { UserProfile } from '@/types';
|
||||
|
||||
let authenticatedAppInitialization: Promise<void> | null = null;
|
||||
|
||||
export async function initializeAuthenticatedApp() {
|
||||
if (authenticatedAppInitialization) {
|
||||
return authenticatedAppInitialization;
|
||||
}
|
||||
|
||||
authenticatedAppInitialization = loadAuthenticatedApp().finally(() => {
|
||||
authenticatedAppInitialization = null;
|
||||
});
|
||||
|
||||
return authenticatedAppInitialization;
|
||||
}
|
||||
|
||||
async function loadAuthenticatedApp() {
|
||||
const [{ user, tenant }, permissionsResponse] = await Promise.all([
|
||||
authService.me(),
|
||||
authService.permissions(),
|
||||
]);
|
||||
|
||||
const permissions = permissionsResponse.granted_permissions ?? [];
|
||||
|
||||
useAppStore.getState().setUserContext({
|
||||
user: user as UserProfile,
|
||||
tenant,
|
||||
permissions: permissionsResponse.granted_permissions ?? [],
|
||||
permissions,
|
||||
defaultRoute: getFirstAccessiblePathForPermissions(permissions),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface AppState {
|
||||
user: UserProfile | null;
|
||||
tenant: TenantInfo | null;
|
||||
permissions: string[];
|
||||
defaultRoute: string | null;
|
||||
loadStatus: AppLoadStatus;
|
||||
isInitialized: boolean;
|
||||
setLoading: () => void;
|
||||
@@ -16,6 +17,7 @@ interface AppState {
|
||||
user: UserProfile;
|
||||
tenant?: TenantInfo | null;
|
||||
permissions?: string[];
|
||||
defaultRoute?: string | null;
|
||||
}) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
@@ -24,16 +26,23 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
user: null,
|
||||
tenant: null,
|
||||
permissions: [],
|
||||
defaultRoute: null,
|
||||
loadStatus: 'idle',
|
||||
isInitialized: false,
|
||||
setLoading: () => set({ loadStatus: 'loading', isInitialized: false }),
|
||||
setLoaded: () => set({ loadStatus: 'loaded', isInitialized: true }),
|
||||
setLoadError: () => set({ loadStatus: 'error', isInitialized: true }),
|
||||
setUserContext: ({ user, tenant = null, permissions = [] }) =>
|
||||
setUserContext: ({
|
||||
user,
|
||||
tenant = null,
|
||||
permissions = [],
|
||||
defaultRoute = null,
|
||||
}) =>
|
||||
set({
|
||||
user,
|
||||
tenant,
|
||||
permissions,
|
||||
defaultRoute,
|
||||
loadStatus: 'loaded',
|
||||
isInitialized: true,
|
||||
}),
|
||||
@@ -42,6 +51,7 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
user: null,
|
||||
tenant: null,
|
||||
permissions: [],
|
||||
defaultRoute: null,
|
||||
loadStatus: 'idle',
|
||||
isInitialized: false,
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,6 @@ export const ROUTES = {
|
||||
FORGOT_PASSWORD: '/forgot-password',
|
||||
RESET_PASSWORD: '/reset-password',
|
||||
SET_PASSWORD: '/set-password',
|
||||
DASHBOARD: '/dashboard',
|
||||
PROJECT: '/project',
|
||||
PACKAGE: '/package',
|
||||
CHAINAGE: '/segment',
|
||||
|
||||
Reference in New Issue
Block a user