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:
vergiLgood1 2025-05-15 14:15:18 +07:00
parent c3eeb4051e
commit 2ab60befd8
5 changed files with 819 additions and 371 deletions

View File

@ -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>
); )
} }

View File

@ -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>

View File

@ -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>
</> </>
) );
} }

View File

@ -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>

View File

@ -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">