feat: Enhance Crime Sidebar and Incident Tab with Recent Incidents
- Updated CrimeSidebar to accept recent incidents as a prop for user reports from the last 24 hours. - Modified SidebarIncidentsTab to format and display recent incidents, including filtering by category and sorting by timestamp. - Adjusted CrimeMap to manage the selected year based on the source type and to handle recent incidents data. - Renamed MapLegend to ClusterLegend for clarity and updated its props accordingly.
This commit is contained in:
parent
c3eeb4051e
commit
2ab60befd8
|
@ -1,75 +1,288 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { AlertTriangle, MapPin, Calendar } from 'lucide-react'
|
import { Info, Clock, MapPin, AlertTriangle, ChevronRight, AlertCircle, Shield } from 'lucide-react'
|
||||||
|
import { Card, CardContent, CardHeader, CardFooter } from "@/app/_components/ui/card"
|
||||||
import { Badge } from "@/app/_components/ui/badge"
|
import { Badge } from "@/app/_components/ui/badge"
|
||||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
import { cn } from "@/app/_lib/utils"
|
||||||
|
|
||||||
interface IIncidentCardProps {
|
interface IIncidentCardProps {
|
||||||
title: string
|
title: string
|
||||||
time: string
|
|
||||||
location: string
|
location: string
|
||||||
severity: "Low" | "Medium" | "High" | "Critical"
|
time: string
|
||||||
|
severity?: number | "Low" | "Medium" | "High" | "Critical"
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
showTimeAgo?: boolean
|
showTimeAgo?: boolean
|
||||||
|
status?: string | true
|
||||||
|
isUserReport?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IncidentCard({
|
export function IncidentCard({
|
||||||
title,
|
title,
|
||||||
time,
|
|
||||||
location,
|
location,
|
||||||
severity,
|
time,
|
||||||
|
severity = 1,
|
||||||
onClick,
|
onClick,
|
||||||
showTimeAgo = true
|
className,
|
||||||
|
showTimeAgo = true,
|
||||||
|
status,
|
||||||
|
isUserReport = false
|
||||||
}: IIncidentCardProps) {
|
}: IIncidentCardProps) {
|
||||||
const getBadgeColor = () => {
|
// Helper to normalize severity to a number
|
||||||
switch (severity) {
|
const normalizeSeverity = (sev: number | "Low" | "Medium" | "High" | "Critical"): number => {
|
||||||
case "Low": return "bg-green-500/20 text-green-300";
|
if (typeof sev === "number") return sev;
|
||||||
case "Medium": return "bg-yellow-500/20 text-yellow-300";
|
switch (sev) {
|
||||||
case "High": return "bg-orange-500/20 text-orange-300";
|
case "Critical":
|
||||||
case "Critical": return "bg-red-500/20 text-red-300";
|
return 4;
|
||||||
default: return "bg-gray-500/20 text-gray-300";
|
case "High":
|
||||||
|
return 3;
|
||||||
|
case "Medium":
|
||||||
|
return 2;
|
||||||
|
case "Low":
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBorderColor = () => {
|
const getSeverityColor = (severity: number) => {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case "Low": return "border-l-green-500";
|
case 4:
|
||||||
case "Medium": return "border-l-yellow-500";
|
return "border-purple-600 bg-purple-50 dark:bg-purple-950/30"
|
||||||
case "High": return "border-l-orange-500";
|
case 3:
|
||||||
case "Critical": return "border-l-red-500";
|
return "border-red-500 bg-red-50 dark:bg-red-950/30"
|
||||||
default: return "border-l-gray-500";
|
case 2:
|
||||||
|
return "border-yellow-400 bg-yellow-50 dark:bg-yellow-950/30"
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return "border-blue-500 bg-blue-50 dark:bg-blue-950/30"
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const getSeverityText = (severity: number) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 4:
|
||||||
|
return "text-purple-600 dark:text-purple-400"
|
||||||
|
case 3:
|
||||||
|
return "text-red-500 dark:text-red-400"
|
||||||
|
case 2:
|
||||||
|
return "text-yellow-600 dark:text-yellow-400"
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return "text-blue-500 dark:text-blue-400"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeverityLabel = (severity: number) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 4:
|
||||||
|
return "Critical"
|
||||||
|
case 3:
|
||||||
|
return "High"
|
||||||
|
case 2:
|
||||||
|
return "Medium"
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return "Low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case "resolved":
|
||||||
|
return "bg-green-100 text-green-700 border-green-300 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800"
|
||||||
|
case "pending":
|
||||||
|
default:
|
||||||
|
return "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSeverity = normalizeSeverity(severity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`bg-white/5 hover:bg-white/10 border-0 text-white shadow-none transition-colors border-l-2 ${getBorderColor()} ${onClick ? 'cursor-pointer' : ''}`}
|
className={cn(
|
||||||
|
"overflow-hidden transition-all duration-200 border-l-4",
|
||||||
|
getSeverityColor(normalizedSeverity),
|
||||||
|
"hover:shadow-md hover:translate-y-[-2px]",
|
||||||
|
"group cursor-pointer",
|
||||||
|
className
|
||||||
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<CardContent className="p-3 text-xs">
|
<CardContent className="p-4 relative">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
<div className="flex-1">
|
||||||
<div>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="flex items-center justify-between">
|
<Badge className={`${getSeverityText(normalizedSeverity)} bg-white dark:bg-transparent border`}>
|
||||||
<p className="font-medium">{title}</p>
|
{getSeverityLabel(normalizedSeverity)}
|
||||||
<Badge className={`${getBadgeColor()} text-[9px] h-4 ml-1`}>{severity}</Badge>
|
</Badge>
|
||||||
</div>
|
{isUserReport && (
|
||||||
<div className="flex items-center gap-2 mt-1.5 text-white/60">
|
<Badge variant="outline" className="text-xs">
|
||||||
<MapPin className="h-3 w-3" />
|
User Report
|
||||||
<span>{location}</span>
|
</Badge>
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 text-white/60 flex items-center gap-1">
|
|
||||||
{showTimeAgo ? (
|
|
||||||
time
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Calendar className="h-3 w-3" />
|
|
||||||
<span>{time}</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-semibold text-base mb-2 line-clamp-1">{title}</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-y-2 gap-x-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{time}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate max-w-[180px]">{location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add status badges for user reports */}
|
||||||
|
{isUserReport && typeof status === "string" && (
|
||||||
|
<Badge variant="outline" className={`mt-3 ${getStatusColor(status)}`}>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IncidentCardV2({
|
||||||
|
title,
|
||||||
|
location,
|
||||||
|
time,
|
||||||
|
severity = 1,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
showTimeAgo = true,
|
||||||
|
status,
|
||||||
|
isUserReport = false,
|
||||||
|
}: IIncidentCardProps) {
|
||||||
|
// Helper to normalize severity to a number
|
||||||
|
const normalizeSeverity = (sev: number | "Low" | "Medium" | "High" | "Critical"): number => {
|
||||||
|
if (typeof sev === "number") return sev
|
||||||
|
switch (sev) {
|
||||||
|
case "Critical":
|
||||||
|
return 4
|
||||||
|
case "High":
|
||||||
|
return 3
|
||||||
|
case "Medium":
|
||||||
|
return 2
|
||||||
|
case "Low":
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeverityGradient = (severity: number) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 4:
|
||||||
|
return "bg-gradient-to-r from-purple-500/10 to-purple-600/5 dark:from-purple-900/20 dark:to-purple-800/10"
|
||||||
|
case 3:
|
||||||
|
return "bg-gradient-to-r from-red-500/10 to-red-600/5 dark:from-red-900/20 dark:to-red-800/10"
|
||||||
|
case 2:
|
||||||
|
return "bg-gradient-to-r from-yellow-500/10 to-yellow-600/5 dark:from-yellow-900/20 dark:to-yellow-800/10"
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return "bg-gradient-to-r from-blue-500/10 to-blue-600/5 dark:from-blue-900/20 dark:to-blue-800/10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeverityIconColor = (severity: number) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 4:
|
||||||
|
return "text-purple-600 dark:text-purple-400"
|
||||||
|
case 3:
|
||||||
|
return "text-red-500 dark:text-red-400"
|
||||||
|
case 2:
|
||||||
|
return "text-yellow-600 dark:text-yellow-400"
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return "text-blue-500 dark:text-blue-400"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeverityIcon = (severity: number) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 4:
|
||||||
|
case 3:
|
||||||
|
return <AlertCircle className={`h-6 w-6 ${getSeverityIconColor(severity)}`} />
|
||||||
|
case 2:
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return <Shield className={`h-6 w-6 ${getSeverityIconColor(severity)}`} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case "resolved":
|
||||||
|
return "bg-green-100 text-green-700 border-green-300 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800"
|
||||||
|
case "pending":
|
||||||
|
default:
|
||||||
|
return "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSeverity = normalizeSeverity(severity)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden transition-all duration-300 border",
|
||||||
|
getSeverityGradient(normalizedSeverity),
|
||||||
|
"hover:shadow-md hover:translate-y-[-2px]",
|
||||||
|
"group cursor-pointer",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex items-stretch">
|
||||||
|
{/* <div className="p-4 flex items-center justify-center">{getSeverityIcon(normalizedSeverity)}</div> */}
|
||||||
|
|
||||||
|
<div className="flex-1 p-4 pr-10 relative">
|
||||||
|
<h3 className="font-medium text-base mb-2 line-clamp-1">{title}</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{time}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate max-w-[180px]">{location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badges */}
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
{isUserReport && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
User Report
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUserReport && typeof status === "string" && (
|
||||||
|
<Badge variant="outline" className={`${getStatusColor(status)}`}>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {
|
||||||
import { Button } from "@/app/_components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { Skeleton } from "@/app/_components/ui/skeleton";
|
import { Skeleton } from "@/app/_components/ui/skeleton";
|
||||||
import { useMap } from "react-map-gl/mapbox";
|
import { useMap } from "react-map-gl/mapbox";
|
||||||
import { ICrimes } from "@/app/_utils/types/crimes";
|
import { ICrimes, IIncidentLogs } from "@/app/_utils/types/crimes";
|
||||||
|
|
||||||
// Import sidebar components
|
// Import sidebar components
|
||||||
import { SidebarIncidentsTab } from "./tabs/incidents-tab";
|
import { SidebarIncidentsTab } from "./tabs/incidents-tab";
|
||||||
|
@ -43,6 +43,7 @@ interface CrimeSidebarProps {
|
||||||
selectedYear: number | "all";
|
selectedYear: number | "all";
|
||||||
selectedMonth?: number | "all";
|
selectedMonth?: number | "all";
|
||||||
crimes: ICrimes[];
|
crimes: ICrimes[];
|
||||||
|
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
sourceType?: string;
|
sourceType?: string;
|
||||||
}
|
}
|
||||||
|
@ -54,6 +55,7 @@ export default function CrimeSidebar({
|
||||||
selectedYear,
|
selectedYear,
|
||||||
selectedMonth,
|
selectedMonth,
|
||||||
crimes = [],
|
crimes = [],
|
||||||
|
recentIncidents = [], // User reports from last 24 hours
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
sourceType = "cbt",
|
sourceType = "cbt",
|
||||||
}: CrimeSidebarProps) {
|
}: CrimeSidebarProps) {
|
||||||
|
@ -244,7 +246,7 @@ export default function CrimeSidebar({
|
||||||
activeIncidentTab={activeIncidentTab}
|
activeIncidentTab={activeIncidentTab}
|
||||||
setActiveIncidentTab={setActiveIncidentTab}
|
setActiveIncidentTab={setActiveIncidentTab}
|
||||||
sourceType={sourceType}
|
sourceType={sourceType}
|
||||||
// setActiveTab={setActiveTab} // Pass setActiveTab function
|
recentIncidents={recentIncidents} // Pass the recentIncidents
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,45 @@
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar, ArrowRight, RefreshCw } from 'lucide-react'
|
import {
|
||||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
AlertCircle,
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
|
AlertTriangle,
|
||||||
import { Badge } from "@/app/_components/ui/badge"
|
ArrowRight,
|
||||||
import { Button } from "@/app/_components/ui/button"
|
Calendar,
|
||||||
import { formatMonthKey, getIncidentSeverity, getMonthName, getTimeAgo } from "@/app/_utils/common"
|
ChevronLeft,
|
||||||
import { SystemStatusCard } from "../components/system-status-card"
|
ChevronRight,
|
||||||
import { IncidentCard } from "../components/incident-card"
|
Clock,
|
||||||
import { ICrimeAnalytics } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics'
|
FileText,
|
||||||
|
MapPin,
|
||||||
|
RefreshCw,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent } from "@/app/_components/ui/card";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/app/_components/ui/tabs";
|
||||||
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import {
|
||||||
|
formatMonthKey,
|
||||||
|
getIncidentSeverity,
|
||||||
|
getMonthName,
|
||||||
|
getTimeAgo,
|
||||||
|
} from "@/app/_utils/common";
|
||||||
|
import { SystemStatusCard } from "../components/system-status-card";
|
||||||
|
import { IncidentCard, IncidentCardV2 } from "../components/incident-card";
|
||||||
|
import { ICrimeAnalytics } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics";
|
||||||
|
import { IIncidentLogs } from "@/app/_utils/types/crimes";
|
||||||
|
|
||||||
interface Incident {
|
interface Incident {
|
||||||
id: string;
|
id: string;
|
||||||
category: string;
|
category: string;
|
||||||
address: string;
|
address: string;
|
||||||
timestamp: string;
|
timestamp: string | Date; // Accept both string and Date for timestamp
|
||||||
district?: string;
|
district?: string;
|
||||||
severity?: number;
|
severity?: number | "Low" | "Medium" | "High" | "Critical"; // Match severity types
|
||||||
status?: string;
|
status?: string | true; // Match status types
|
||||||
description?: string;
|
description?: string;
|
||||||
location?: {
|
location?: {
|
||||||
lat: number;
|
lat: number;
|
||||||
|
@ -34,16 +57,11 @@ interface SidebarIncidentsTabProps {
|
||||||
selectedCategory: string | "all";
|
selectedCategory: string | "all";
|
||||||
getTimePeriodDisplay: () => string;
|
getTimePeriodDisplay: () => string;
|
||||||
paginationState: Record<string, number>;
|
paginationState: Record<string, number>;
|
||||||
handlePageChange: (monthKey: string, direction: 'next' | 'prev') => void;
|
handlePageChange: (monthKey: string, direction: "next" | "prev") => void;
|
||||||
handleIncidentClick: (incident: Incident) => void;
|
handleIncidentClick: (incident: Incident) => void;
|
||||||
activeIncidentTab: string;
|
activeIncidentTab: string;
|
||||||
setActiveIncidentTab: (tab: string) => void;
|
setActiveIncidentTab: (tab: string) => void;
|
||||||
}
|
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
|
||||||
|
|
||||||
interface CrimeCategory {
|
|
||||||
type: string;
|
|
||||||
count: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarIncidentsTab({
|
export function SidebarIncidentsTab({
|
||||||
|
@ -60,16 +78,58 @@ export function SidebarIncidentsTab({
|
||||||
handleIncidentClick,
|
handleIncidentClick,
|
||||||
activeIncidentTab,
|
activeIncidentTab,
|
||||||
setActiveIncidentTab,
|
setActiveIncidentTab,
|
||||||
sourceType = "cbt"
|
sourceType = "cbt",
|
||||||
|
recentIncidents = [],
|
||||||
}: SidebarIncidentsTabProps & { sourceType?: string }) {
|
}: SidebarIncidentsTabProps & { sourceType?: string }) {
|
||||||
const topCategories = crimeStats.categoryCounts ?
|
const currentYear = new Date().getFullYear();
|
||||||
Object.entries(crimeStats.categoryCounts)
|
const isCurrentYear = selectedYear === currentYear ||
|
||||||
|
selectedYear === "all";
|
||||||
|
|
||||||
|
const topCategories = crimeStats.categoryCounts
|
||||||
|
? Object.entries(crimeStats.categoryCounts)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map(([type, count]) => {
|
.map(([type, count]) => {
|
||||||
const percentage = Math.round((count / crimeStats.totalIncidents) * 100) || 0
|
const percentage =
|
||||||
return { type, count, percentage }
|
Math.round((count / crimeStats.totalIncidents) * 100) || 0;
|
||||||
}) : []
|
return { type, count, percentage };
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Filter history incidents by the selected year
|
||||||
|
const filteredAvailableMonths = crimeStats.availableMonths.filter(
|
||||||
|
(monthKey) => {
|
||||||
|
// If selectedYear is "all", show all months
|
||||||
|
if (selectedYear === "all") return true;
|
||||||
|
|
||||||
|
// Extract year from the monthKey (format: YYYY-MM)
|
||||||
|
const yearFromKey = parseInt(monthKey.split("-")[0]);
|
||||||
|
return yearFromKey === selectedYear;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format recent incidents data from user reports
|
||||||
|
const formattedRecentIncidents = recentIncidents
|
||||||
|
.filter((incident) => {
|
||||||
|
// Filter by category if needed
|
||||||
|
return selectedCategory === "all" ||
|
||||||
|
incident.category?.toLowerCase() ===
|
||||||
|
selectedCategory.toLowerCase();
|
||||||
|
})
|
||||||
|
.map((incident) => ({
|
||||||
|
id: incident.id,
|
||||||
|
category: incident.category || "Uncategorized",
|
||||||
|
address: incident.address || "Unknown location",
|
||||||
|
timestamp: incident.timestamp
|
||||||
|
? incident.timestamp.toString()
|
||||||
|
: new Date().toISOString(), // Convert to string
|
||||||
|
description: incident.description || "",
|
||||||
|
status: incident.verified || "pending",
|
||||||
|
severity: getIncidentSeverity(incident),
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
// If source type is CBU, display warning instead of regular content
|
// If source type is CBU, display warning instead of regular content
|
||||||
if (sourceType === "cbu") {
|
if (sourceType === "cbu") {
|
||||||
|
@ -80,26 +140,39 @@ export function SidebarIncidentsTab({
|
||||||
<AlertTriangle className="h-8 w-8 text-emerald-400" />
|
<AlertTriangle className="h-8 w-8 text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-medium text-white mb-2">Limited Data View</h3>
|
<h3 className="text-lg font-medium text-white mb-2">
|
||||||
|
Limited Data View
|
||||||
|
</h3>
|
||||||
|
|
||||||
<p className="text-white/80 mb-4">
|
<p className="text-white/80 mb-4">
|
||||||
The CBU data source only provides aggregated statistics without detailed incident information.
|
The CBU data source only provides aggregated statistics
|
||||||
|
without detailed incident information.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-black/20 rounded-lg p-3 w-full mb-4">
|
<div className="bg-black/20 rounded-lg p-3 w-full mb-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-white/60 text-sm">Current Data Source:</span>
|
<span className="text-white/60 text-sm">
|
||||||
<span className="font-medium text-emerald-400 text-sm">CBU</span>
|
Current Data Source:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-emerald-400 text-sm">
|
||||||
|
CBU
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-white/60 text-sm">Recommended:</span>
|
<span className="text-white/60 text-sm">
|
||||||
<span className="font-medium text-blue-400 text-sm">CBT</span>
|
Recommended:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-blue-400 text-sm">
|
||||||
|
CBT
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-white/70 text-sm mb-5">
|
<p className="text-white/70 text-sm mb-5">
|
||||||
To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source.
|
To view detailed incident reports, individual crime
|
||||||
|
records, and location-specific information, please
|
||||||
|
switch to the CBT data source.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
@ -115,7 +188,9 @@ export function SidebarIncidentsTab({
|
||||||
|
|
||||||
<div className="w-full mt-6 pt-3 border-t border-emerald-500/20">
|
<div className="w-full mt-6 pt-3 border-t border-emerald-500/20">
|
||||||
<p className="text-xs text-white/60">
|
<p className="text-xs text-white/60">
|
||||||
The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information.
|
The CBU (Crime By Unit) data provides insights at
|
||||||
|
the district level, while CBT (Crime By Type)
|
||||||
|
includes detailed incident-level information.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
@ -128,8 +203,10 @@ export function SidebarIncidentsTab({
|
||||||
{/* Enhanced info card */}
|
{/* Enhanced info card */}
|
||||||
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden">
|
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden">
|
||||||
<CardContent className="p-4 text-sm relative">
|
<CardContent className="p-4 text-sm relative">
|
||||||
<div className="absolute top-0 right-0 w-24 h-24 bg-sidebar-primary/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
<div className="absolute top-0 right-0 w-24 h-24 bg-sidebar-primary/10 rounded-full -translate-y-1/2 translate-x-1/2">
|
||||||
<div className="absolute bottom-0 left-0 w-16 h-16 bg-sidebar-primary/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
</div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-16 h-16 bg-sidebar-primary/10 rounded-full translate-y-1/2 -translate-x-1/2">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
@ -143,13 +220,19 @@ export function SidebarIncidentsTab({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<MapPin className="h-4 w-4 text-sidebar-primary" />
|
<MapPin className="h-4 w-4 text-sidebar-primary" />
|
||||||
<span className="text-sidebar-foreground/70">{location}</span>
|
<span className="text-sidebar-foreground/70">
|
||||||
|
{location}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg">
|
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg">
|
||||||
<AlertTriangle className="h-5 w-5 text-emerald-400" />
|
<AlertTriangle className="h-5 w-5 text-emerald-400" />
|
||||||
<span>
|
<span>
|
||||||
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
|
<strong>{crimeStats.totalIncidents || 0}</strong>
|
||||||
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
|
{" "}
|
||||||
|
incidents reported
|
||||||
|
{selectedMonth !== "all"
|
||||||
|
? ` in ${getMonthName(Number(selectedMonth))}`
|
||||||
|
: ` in ${selectedYear}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
@ -160,7 +243,9 @@ export function SidebarIncidentsTab({
|
||||||
<SystemStatusCard
|
<SystemStatusCard
|
||||||
title="Total Cases"
|
title="Total Cases"
|
||||||
status={`${crimeStats?.totalIncidents || 0}`}
|
status={`${crimeStats?.totalIncidents || 0}`}
|
||||||
statusIcon={<AlertCircle className="h-4 w-4 text-green-400" />}
|
statusIcon={
|
||||||
|
<AlertCircle className="h-4 w-4 text-green-400" />
|
||||||
|
}
|
||||||
statusColor="text-green-400"
|
statusColor="text-green-400"
|
||||||
updatedTime={getTimePeriodDisplay()}
|
updatedTime={getTimePeriodDisplay()}
|
||||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||||
|
@ -177,7 +262,9 @@ export function SidebarIncidentsTab({
|
||||||
/>
|
/>
|
||||||
<SystemStatusCard
|
<SystemStatusCard
|
||||||
title="Top Category"
|
title="Top Category"
|
||||||
status={topCategories.length > 0 ? topCategories[0].type : "None"}
|
status={topCategories.length > 0
|
||||||
|
? topCategories[0].type
|
||||||
|
: "None"}
|
||||||
statusIcon={<Shield className="h-4 w-4 text-green-400" />}
|
statusIcon={<Shield className="h-4 w-4 text-green-400" />}
|
||||||
statusColor="text-green-400"
|
statusColor="text-green-400"
|
||||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||||
|
@ -224,30 +311,81 @@ export function SidebarIncidentsTab({
|
||||||
|
|
||||||
{/* Recent Incidents Tab Content */}
|
{/* Recent Incidents Tab Content */}
|
||||||
<TabsContent value="recent" className="m-0 p-0">
|
<TabsContent value="recent" className="m-0 p-0">
|
||||||
{crimeStats.recentIncidents.length === 0 ? (
|
{!isCurrentYear
|
||||||
|
? (
|
||||||
|
<Card className="bg-amber-900/20 border border-amber-500/30 mb-3">
|
||||||
|
<CardContent className="p-4 flex flex-col items-center text-center">
|
||||||
|
<div className="bg-amber-500/20 rounded-full p-2 mb-2">
|
||||||
|
<Calendar className="h-5 w-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-medium text-amber-200">
|
||||||
|
Year Selection Notice
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-amber-100/80 mt-1 mb-2">
|
||||||
|
Recent incidents are only available for
|
||||||
|
the current year ({currentYear}).
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs border-amber-500/50 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300"
|
||||||
|
onClick={() =>
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("set-year", {
|
||||||
|
detail: currentYear,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Switch to {currentYear}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
: formattedRecentIncidents.length === 0
|
||||||
|
? (
|
||||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||||
<CardContent className="p-4 text-center">
|
<CardContent className="p-4 text-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<AlertCircle className="h-6 w-6 text-white/40" />
|
<AlertCircle className="h-6 w-6 text-white/40" />
|
||||||
<p className="text-sm text-white/70">
|
<p className="text-sm text-white/70">
|
||||||
{selectedCategory !== "all"
|
{selectedCategory !== "all"
|
||||||
? `No ${selectedCategory} incidents found`
|
? `No ${selectedCategory} incidents reported in the last 24 hours`
|
||||||
: "No recent incidents reported"}
|
: "No incidents reported in the last 24 hours"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/50">
|
||||||
|
The most recent user reports will
|
||||||
|
appear here
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-white/50">Try adjusting your filters or checking back later</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{crimeStats.recentIncidents.slice(0, 6).map((incident: Incident) => (
|
{formattedRecentIncidents.slice(0, 6).map((
|
||||||
<IncidentCard
|
incident,
|
||||||
|
) => (
|
||||||
|
<IncidentCardV2
|
||||||
key={incident.id}
|
key={incident.id}
|
||||||
title={`${incident.category || 'Unknown'} in ${incident.address?.split(',')[0] || 'Unknown Location'}`}
|
title={`${incident.category || "Unknown"
|
||||||
time={incident.timestamp ? getTimeAgo(incident.timestamp) : 'Unknown time'}
|
} in ${incident.address?.split(",")[0] ||
|
||||||
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
"Unknown Location"
|
||||||
severity={getIncidentSeverity(incident)}
|
}`}
|
||||||
onClick={() => handleIncidentClick(incident)}
|
time={typeof incident.timestamp ===
|
||||||
|
"string"
|
||||||
|
? getTimeAgo(incident.timestamp)
|
||||||
|
: getTimeAgo(incident.timestamp)}
|
||||||
|
location={incident.address?.split(",")
|
||||||
|
.slice(1, 3).join(", ") ||
|
||||||
|
"Unknown Location"}
|
||||||
|
severity={incident.severity}
|
||||||
|
onClick={() =>
|
||||||
|
handleIncidentClick(
|
||||||
|
incident as Incident,
|
||||||
|
)}
|
||||||
|
status={incident.status}
|
||||||
|
isUserReport={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -256,63 +394,135 @@ export function SidebarIncidentsTab({
|
||||||
|
|
||||||
{/* History Incidents Tab Content */}
|
{/* History Incidents Tab Content */}
|
||||||
<TabsContent value="history" className="m-0 p-0">
|
<TabsContent value="history" className="m-0 p-0">
|
||||||
{crimeStats.availableMonths && crimeStats.availableMonths.length === 0 ? (
|
{filteredAvailableMonths.length === 0
|
||||||
|
? (
|
||||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||||
<CardContent className="p-4 text-center">
|
<CardContent className="p-4 text-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<FileText className="h-6 w-6 text-white/40" />
|
<FileText className="h-6 w-6 text-white/40" />
|
||||||
<p className="text-sm text-white/70">
|
<p className="text-sm text-white/70">
|
||||||
{selectedCategory !== "all"
|
{selectedCategory !== "all"
|
||||||
? `No ${selectedCategory} incidents found in the selected period`
|
? `No ${selectedCategory} incidents found in ${selectedYear === "all"
|
||||||
: "No incidents found in the selected period"}
|
? "any period"
|
||||||
|
: selectedYear
|
||||||
|
}`
|
||||||
|
: `No incidents found in ${selectedYear === "all"
|
||||||
|
? "any period"
|
||||||
|
: selectedYear
|
||||||
|
}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/50">
|
||||||
|
Try adjusting your filters
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-white/50">Try adjusting your filters</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<span className="text-xs text-white/60">
|
<span className="text-xs text-white/60">
|
||||||
Showing incidents from {crimeStats.availableMonths.length} {crimeStats.availableMonths.length === 1 ? 'month' : 'months'}
|
Showing incidents from{" "}
|
||||||
|
{filteredAvailableMonths.length}{" "}
|
||||||
|
{filteredAvailableMonths.length === 1
|
||||||
|
? "month"
|
||||||
|
: "months"}
|
||||||
|
{selectedYear !== "all"
|
||||||
|
? ` in ${selectedYear}`
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className="h-5 text-[10px]">
|
<Badge
|
||||||
{selectedCategory !== "all" ? selectedCategory : "All Categories"}
|
variant="outline"
|
||||||
|
className="h-5 text-[10px]"
|
||||||
|
>
|
||||||
|
{selectedCategory !== "all"
|
||||||
|
? selectedCategory
|
||||||
|
: "All Categories"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{crimeStats.availableMonths.map((monthKey: string) => {
|
{filteredAvailableMonths.map(
|
||||||
const incidents = crimeStats.incidentsByMonthDetail[monthKey] || []
|
(monthKey: string) => {
|
||||||
const pageSize = 5
|
const incidents = crimeStats
|
||||||
const currentPage = paginationState[monthKey] || 0
|
.incidentsByMonthDetail[
|
||||||
const totalPages = Math.ceil(incidents.length / pageSize)
|
monthKey
|
||||||
const startIdx = currentPage * pageSize
|
] || [];
|
||||||
const endIdx = startIdx + pageSize
|
const pageSize = 5;
|
||||||
const paginatedIncidents = incidents.slice(startIdx, endIdx)
|
const currentPage =
|
||||||
|
paginationState[monthKey] || 0;
|
||||||
|
const totalPages = Math.ceil(
|
||||||
|
incidents.length / pageSize,
|
||||||
|
);
|
||||||
|
const startIdx = currentPage * pageSize;
|
||||||
|
const endIdx = startIdx + pageSize;
|
||||||
|
const paginatedIncidents = incidents
|
||||||
|
.slice(startIdx, endIdx);
|
||||||
|
|
||||||
if (incidents.length === 0) return null
|
if (incidents.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={monthKey} className="mb-5">
|
<div
|
||||||
|
key={monthKey}
|
||||||
|
className="mb-5"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Calendar className="h-3.5 w-3.5 text-emerald-400" />
|
<Calendar className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
<h4 className="font-medium text-xs">{formatMonthKey(monthKey)}</h4>
|
<h4 className="font-medium text-xs">
|
||||||
|
{formatMonthKey(
|
||||||
|
monthKey,
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
<Badge
|
||||||
{incidents.length} incident{incidents.length !== 1 ? 's' : ''}
|
variant="secondary"
|
||||||
|
className="h-5 text-[10px]"
|
||||||
|
>
|
||||||
|
{incidents.length}{" "}
|
||||||
|
incident{incidents
|
||||||
|
.length !== 1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{paginatedIncidents.map((incident: Incident) => (
|
{paginatedIncidents.map((
|
||||||
<IncidentCard
|
incident: Incident,
|
||||||
|
) => (
|
||||||
|
<IncidentCardV2
|
||||||
key={incident.id}
|
key={incident.id}
|
||||||
title={`${incident.category || 'Unknown'} in ${incident.address?.split(',')[0] || 'Unknown Location'}`}
|
title={`${incident
|
||||||
time={incident.timestamp ? new Date(incident.timestamp).toLocaleDateString() : 'Unknown date'}
|
.category ||
|
||||||
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
"Unknown"
|
||||||
severity={getIncidentSeverity(incident)}
|
} in ${incident.address
|
||||||
onClick={() => handleIncidentClick(incident)}
|
?.split(
|
||||||
|
",",
|
||||||
|
)[0] ||
|
||||||
|
"Unknown Location"
|
||||||
|
}`}
|
||||||
|
time={incident
|
||||||
|
.timestamp
|
||||||
|
? new Date(
|
||||||
|
incident
|
||||||
|
.timestamp,
|
||||||
|
).toLocaleDateString()
|
||||||
|
: "Unknown date"}
|
||||||
|
location={incident
|
||||||
|
.address?.split(
|
||||||
|
",",
|
||||||
|
).slice(1, 3)
|
||||||
|
.join(", ") ||
|
||||||
|
"Unknown Location"}
|
||||||
|
severity={getIncidentSeverity(
|
||||||
|
incident,
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
handleIncidentClick(
|
||||||
|
incident,
|
||||||
|
)}
|
||||||
showTimeAgo={false}
|
showTimeAgo={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -321,15 +531,23 @@ export function SidebarIncidentsTab({
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-between mt-2">
|
||||||
<span className="text-xs text-white/50">
|
<span className="text-xs text-white/50">
|
||||||
Page {currentPage + 1} of {totalPages}
|
Page{" "}
|
||||||
|
{currentPage + 1} of
|
||||||
|
{" "}
|
||||||
|
{totalPages}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 py-1 text-[10px]"
|
className="h-7 px-2 py-1 text-[10px]"
|
||||||
disabled={currentPage === 0}
|
disabled={currentPage ===
|
||||||
onClick={() => handlePageChange(monthKey, 'prev')}
|
0}
|
||||||
|
onClick={() =>
|
||||||
|
handlePageChange(
|
||||||
|
monthKey,
|
||||||
|
"prev",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-3 w-3 mr-1" />
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||||
Prev
|
Prev
|
||||||
|
@ -338,8 +556,14 @@ export function SidebarIncidentsTab({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 py-1 text-[10px]"
|
className="h-7 px-2 py-1 text-[10px]"
|
||||||
disabled={currentPage >= totalPages - 1}
|
disabled={currentPage >=
|
||||||
onClick={() => handlePageChange(monthKey, 'next')}
|
totalPages -
|
||||||
|
1}
|
||||||
|
onClick={() =>
|
||||||
|
handlePageChange(
|
||||||
|
monthKey,
|
||||||
|
"next",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-3 w-3 ml-1" />
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
|
@ -348,12 +572,13 @@ export function SidebarIncidentsTab({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { getMonthName } from "@/app/_utils/common";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen";
|
import { useFullscreen } from "@/app/_hooks/use-fullscreen";
|
||||||
import { Overlay } from "./overlay";
|
import { Overlay } from "./overlay";
|
||||||
import MapLegend from "./legends/map-legend";
|
import clusterLegend from "./legends/map-legend";
|
||||||
import UnitsLegend from "./legends/units-legend";
|
import UnitsLegend from "./legends/units-legend";
|
||||||
import TimelineLegend from "./legends/timeline-legend";
|
import TimelineLegend from "./legends/timeline-legend";
|
||||||
import {
|
import {
|
||||||
|
@ -27,16 +27,6 @@ import {
|
||||||
import MapSelectors from "./controls/map-selector";
|
import MapSelectors from "./controls/map-selector";
|
||||||
|
|
||||||
import { cn } from "@/app/_lib/utils";
|
import { cn } from "@/app/_lib/utils";
|
||||||
import {
|
|
||||||
$Enums,
|
|
||||||
crime_categories,
|
|
||||||
crime_incidents,
|
|
||||||
crimes,
|
|
||||||
demographics,
|
|
||||||
districts,
|
|
||||||
geographics,
|
|
||||||
locations,
|
|
||||||
} from "@prisma/client";
|
|
||||||
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse";
|
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse";
|
||||||
import { ITooltipsControl } from "./controls/top/tooltips";
|
import { ITooltipsControl } from "./controls/top/tooltips";
|
||||||
import CrimeSidebar from "./controls/left/sidebar/map-sidebar";
|
import CrimeSidebar from "./controls/left/sidebar/map-sidebar";
|
||||||
|
@ -54,6 +44,7 @@ import {
|
||||||
} from "@/app/_utils/mock/ews-data";
|
} from "@/app/_utils/mock/ews-data";
|
||||||
import { useMap } from "react-map-gl/mapbox";
|
import { useMap } from "react-map-gl/mapbox";
|
||||||
import PanicButtonDemo from "./controls/panic-button-demo";
|
import PanicButtonDemo from "./controls/panic-button-demo";
|
||||||
|
import ClusterLegend from "./legends/map-legend";
|
||||||
|
|
||||||
export default function CrimeMap() {
|
export default function CrimeMap() {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||||
|
@ -64,7 +55,7 @@ export default function CrimeMap() {
|
||||||
IDistrictFeature | null
|
IDistrictFeature | null
|
||||||
>(null);
|
>(null);
|
||||||
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu");
|
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu");
|
||||||
const [selectedYear, setSelectedYear] = useState<number | "all">(2024);
|
const [selectedYear, setSelectedYear] = useState<number | "all">();
|
||||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
|
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | "all">(
|
const [selectedCategory, setSelectedCategory] = useState<string | "all">(
|
||||||
"all",
|
"all",
|
||||||
|
@ -75,7 +66,6 @@ export default function CrimeMap() {
|
||||||
const [useAllMonths, setUseAllMonths] = useState<boolean>(false);
|
const [useAllMonths, setUseAllMonths] = useState<boolean>(false);
|
||||||
|
|
||||||
const [showAllIncidents, setShowAllIncidents] = useState(false);
|
const [showAllIncidents, setShowAllIncidents] = useState(false);
|
||||||
const [showLegend, setShowLegend] = useState<boolean>(true);
|
|
||||||
const [showUnitsLayer, setShowUnitsLayer] = useState(false);
|
const [showUnitsLayer, setShowUnitsLayer] = useState(false);
|
||||||
const [showClusters, setShowClusters] = useState(false);
|
const [showClusters, setShowClusters] = useState(false);
|
||||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
|
@ -128,6 +118,12 @@ export default function CrimeMap() {
|
||||||
|
|
||||||
const { data: recentIncidents } = useGetRecentIncidents();
|
const { data: recentIncidents } = useGetRecentIncidents();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const defaultYear = selectedSourceType === "cbu" ? 2024 : currentYear;
|
||||||
|
setSelectedYear(defaultYear);
|
||||||
|
}, [selectedSourceType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
activeControl === "heatmap" || activeControl === "timeline" ||
|
activeControl === "heatmap" || activeControl === "timeline" ||
|
||||||
|
@ -136,12 +132,13 @@ export default function CrimeMap() {
|
||||||
setSelectedYear("all");
|
setSelectedYear("all");
|
||||||
setUseAllYears(true);
|
setUseAllYears(true);
|
||||||
setUseAllMonths(true);
|
setUseAllMonths(true);
|
||||||
} else {
|
} else if (selectedYear === "all") {
|
||||||
setSelectedYear(2024);
|
const currentYear = new Date().getFullYear();
|
||||||
|
setSelectedYear(selectedSourceType === "cbu" ? 2024 : currentYear);
|
||||||
setUseAllYears(false);
|
setUseAllYears(false);
|
||||||
setUseAllMonths(false);
|
setUseAllMonths(false);
|
||||||
}
|
}
|
||||||
}, [activeControl]);
|
}, [activeControl, selectedSourceType, selectedYear]);
|
||||||
|
|
||||||
const crimesBySourceType = useMemo(() => {
|
const crimesBySourceType = useMemo(() => {
|
||||||
if (!crimes) return [];
|
if (!crimes) return [];
|
||||||
|
@ -218,6 +215,20 @@ export default function CrimeMap() {
|
||||||
setEwsIncidents(getAllIncidents());
|
setEwsIncidents(getAllIncidents());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSetYear = (e: CustomEvent) => {
|
||||||
|
if (typeof e.detail === 'number') {
|
||||||
|
setSelectedYear(e.detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('set-year', handleSetYear as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('set-year', handleSetYear as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTriggerAlert = useCallback(
|
const handleTriggerAlert = useCallback(
|
||||||
(priority: "high" | "medium" | "low") => {
|
(priority: "high" | "medium" | "low") => {
|
||||||
const newIncident = addMockIncident({ priority });
|
const newIncident = addMockIncident({ priority });
|
||||||
|
@ -243,6 +254,10 @@ export default function CrimeMap() {
|
||||||
const handleSourceTypeChange = useCallback((sourceType: string) => {
|
const handleSourceTypeChange = useCallback((sourceType: string) => {
|
||||||
setSelectedSourceType(sourceType);
|
setSelectedSourceType(sourceType);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const defaultYear = sourceType === "cbu" ? 2024 : currentYear;
|
||||||
|
setSelectedYear(defaultYear);
|
||||||
|
|
||||||
if (sourceType === "cbu") {
|
if (sourceType === "cbu") {
|
||||||
setActiveControl("clusters");
|
setActiveControl("clusters");
|
||||||
setShowClusters(true);
|
setShowClusters(true);
|
||||||
|
@ -270,15 +285,16 @@ export default function CrimeMap() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
setSelectedYear(2024);
|
const currentYear = new Date().getFullYear();
|
||||||
|
const defaultYear = selectedSourceType === "cbu" ? 2024 : currentYear;
|
||||||
|
setSelectedYear(defaultYear);
|
||||||
setSelectedMonth("all");
|
setSelectedMonth("all");
|
||||||
setSelectedCategory("all");
|
setSelectedCategory("all");
|
||||||
}, []);
|
}, [selectedSourceType]);
|
||||||
|
|
||||||
const getMapTitle = () => {
|
const getMapTitle = () => {
|
||||||
if (useAllYears) {
|
if (useAllYears) {
|
||||||
return `All Years Data ${
|
return `All Years Data ${selectedCategory !== "all" ? `- ${selectedCategory}` : ""
|
||||||
selectedCategory !== "all" ? `- ${selectedCategory}` : ""
|
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -357,13 +373,19 @@ export default function CrimeMap() {
|
||||||
);
|
);
|
||||||
}, [crimes, crimesBySourceType, filteredCrimes, selectedSourceType]);
|
}, [crimes, crimesBySourceType, filteredCrimes, selectedSourceType]);
|
||||||
|
|
||||||
|
const disableYearMonth = activeControl === "incidents" || activeControl === "heatmap" || activeControl === "timeline"
|
||||||
|
|
||||||
|
const activeIncidents = useMemo(() => {
|
||||||
|
return ewsIncidents.filter((incident) => incident.status === "active");
|
||||||
|
}, [ewsIncidents])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full p-0 border-none shadow-none h-96">
|
<Card className="w-full p-0 border-none shadow-none h-96">
|
||||||
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
|
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
|
||||||
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
|
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
|
||||||
<MapSelectors
|
<MapSelectors
|
||||||
availableYears={availableYears || []}
|
availableYears={availableYears || []}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear ?? "all"}
|
||||||
setSelectedYear={setSelectedYear}
|
setSelectedYear={setSelectedYear}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
setSelectedMonth={setSelectedMonth}
|
setSelectedMonth={setSelectedMonth}
|
||||||
|
@ -417,7 +439,7 @@ export default function CrimeMap() {
|
||||||
<Layers
|
<Layers
|
||||||
crimes={filteredCrimes || []}
|
crimes={filteredCrimes || []}
|
||||||
units={fetchedUnits || []}
|
units={fetchedUnits || []}
|
||||||
year={selectedYear.toString()}
|
year={selectedYear?.toString() ?? "all"}
|
||||||
month={selectedMonth.toString()}
|
month={selectedMonth.toString()}
|
||||||
filterCategory={selectedCategory}
|
filterCategory={selectedCategory}
|
||||||
activeControl={activeControl}
|
activeControl={activeControl}
|
||||||
|
@ -436,24 +458,17 @@ export default function CrimeMap() {
|
||||||
onControlChange={handleControlChange}
|
onControlChange={handleControlChange}
|
||||||
selectedSourceType={selectedSourceType}
|
selectedSourceType={selectedSourceType}
|
||||||
setSelectedSourceType={handleSourceTypeChange}
|
setSelectedSourceType={handleSourceTypeChange}
|
||||||
availableSourceTypes={availableSourceTypes ||
|
availableSourceTypes={availableSourceTypes || []}
|
||||||
[]}
|
selectedYear={selectedYear ?? "all"}
|
||||||
selectedYear={selectedYear}
|
|
||||||
setSelectedYear={setSelectedYear}
|
setSelectedYear={setSelectedYear}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
setSelectedMonth={setSelectedMonth}
|
setSelectedMonth={setSelectedMonth}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
setSelectedCategory={setSelectedCategory}
|
setSelectedCategory={setSelectedCategory}
|
||||||
availableYears={availableYears ||
|
availableYears={availableYears || []}
|
||||||
[]}
|
|
||||||
categories={categories}
|
categories={categories}
|
||||||
crimes={filteredCrimes}
|
crimes={filteredCrimes}
|
||||||
disableYearMonth={activeControl ===
|
disableYearMonth={disableYearMonth}
|
||||||
"incidents" ||
|
|
||||||
activeControl ===
|
|
||||||
"heatmap" ||
|
|
||||||
activeControl ===
|
|
||||||
"timeline"}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -470,31 +485,24 @@ export default function CrimeMap() {
|
||||||
<PanicButtonDemo
|
<PanicButtonDemo
|
||||||
onTriggerAlert={handleTriggerAlert}
|
onTriggerAlert={handleTriggerAlert}
|
||||||
onResolveAllAlerts={handleResolveAllAlerts}
|
onResolveAllAlerts={handleResolveAllAlerts}
|
||||||
activeIncidents={ewsIncidents
|
activeIncidents={activeIncidents}
|
||||||
.filter((inc) =>
|
|
||||||
inc.status ===
|
|
||||||
"active"
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CrimeSidebar
|
<CrimeSidebar
|
||||||
crimes={filteredCrimes || []}
|
crimes={filteredCrimes || []}
|
||||||
|
recentIncidents={recentIncidents || []}
|
||||||
defaultCollapsed={sidebarCollapsed}
|
defaultCollapsed={sidebarCollapsed}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear ?? "all"}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
sourceType={selectedSourceType}
|
sourceType={selectedSourceType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute bottom-20 right-0 z-20 p-2">
|
<div className="absolute bottom-20 right-0 z-20 p-2">
|
||||||
{showClusters && (
|
{showClusters && (
|
||||||
<MapLegend position="bottom-right" />
|
<ClusterLegend position="bottom-right" />
|
||||||
)}
|
|
||||||
|
|
||||||
{!showClusters && (
|
|
||||||
<MapLegend position="bottom-right" />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { CRIME_RATE_COLORS } from "@/app/_utils/const/map";
|
||||||
import { Overlay } from "../overlay";
|
import { Overlay } from "../overlay";
|
||||||
import { ControlPosition } from "mapbox-gl";
|
import { ControlPosition } from "mapbox-gl";
|
||||||
|
|
||||||
interface MapLegendProps {
|
interface IClusterLegendProps {
|
||||||
position?: ControlPosition
|
position?: ControlPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapLegend({ position = "bottom-right" }: MapLegendProps) {
|
export default function ClusterLegend({ position = "bottom-right" }: IClusterLegendProps) {
|
||||||
return (
|
return (
|
||||||
// <Overlay position={position}>
|
// <Overlay position={position}>
|
||||||
<div className="flex flex-row text-xs font-semibold font-sans text-background z-0">
|
<div className="flex flex-row text-xs font-semibold font-sans text-background z-0">
|
||||||
|
|
Loading…
Reference in New Issue