feat(map): enhance CrimeSidebar with filtered crimes and incident click handling
This commit is contained in:
parent
7916680122
commit
593b198b94
|
@ -298,6 +298,7 @@ export default function CrimeMap() {
|
||||||
|
|
||||||
{/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */}
|
{/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */}
|
||||||
<CrimeSidebar
|
<CrimeSidebar
|
||||||
|
crimes={filteredCrimes || []}
|
||||||
defaultCollapsed={sidebarCollapsed}
|
defaultCollapsed={sidebarCollapsed}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
|
|
|
@ -14,82 +14,37 @@ import { getMonthName, formatDateString } from "@/app/_utils/common"
|
||||||
import { Skeleton } from "@/app/_components/ui/skeleton"
|
import { Skeleton } from "@/app/_components/ui/skeleton"
|
||||||
import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
||||||
import { $Enums } from "@prisma/client"
|
import { $Enums } from "@prisma/client"
|
||||||
|
import { useMap } from "react-map-gl/mapbox"
|
||||||
|
import { ICrimes } from "@/app/_utils/types/crimes"
|
||||||
|
|
||||||
interface CrimeSidebarProps {
|
interface CrimeSidebarProps {
|
||||||
className?: string
|
className?: string
|
||||||
defaultCollapsed?: boolean
|
defaultCollapsed?: boolean
|
||||||
selectedCategory?: string | "all"
|
selectedCategory?: string | "all"
|
||||||
selectedYear?: number
|
selectedYear: number
|
||||||
selectedMonth?: number | "all"
|
selectedMonth?: number | "all"
|
||||||
}
|
crimes: ICrimes[]
|
||||||
|
isLoading?: boolean
|
||||||
// Updated interface to match the structure returned by getCrimeByYearAndMonth
|
|
||||||
interface ICrimesProps {
|
|
||||||
id: string
|
|
||||||
district_id: string
|
|
||||||
districts: {
|
|
||||||
name: string
|
|
||||||
geographics?: {
|
|
||||||
address: string
|
|
||||||
land_area: number
|
|
||||||
year: number
|
|
||||||
}[]
|
|
||||||
demographics?: {
|
|
||||||
number_of_unemployed: number
|
|
||||||
population: number
|
|
||||||
population_density: number
|
|
||||||
year: number
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
number_of_crime: number
|
|
||||||
level: $Enums.crime_rates
|
|
||||||
score: number
|
|
||||||
month: number
|
|
||||||
year: number
|
|
||||||
crime_incidents: Array<{
|
|
||||||
id: string
|
|
||||||
timestamp: Date
|
|
||||||
description: string
|
|
||||||
status: string
|
|
||||||
crime_categories: {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
locations: {
|
|
||||||
address: string
|
|
||||||
latitude: number
|
|
||||||
longitude: number
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrimeSidebar({
|
export default function CrimeSidebar({
|
||||||
className,
|
className,
|
||||||
defaultCollapsed = true,
|
defaultCollapsed = true,
|
||||||
selectedCategory = "all",
|
selectedCategory = "all",
|
||||||
selectedYear: propSelectedYear,
|
selectedYear,
|
||||||
selectedMonth: propSelectedMonth
|
selectedMonth,
|
||||||
|
crimes = [],
|
||||||
|
isLoading = false,
|
||||||
}: CrimeSidebarProps) {
|
}: CrimeSidebarProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
||||||
const [activeTab, setActiveTab] = useState("incidents")
|
const [activeTab, setActiveTab] = useState("incidents")
|
||||||
|
const [activeIncidentTab, setActiveIncidentTab] = useState("recent")
|
||||||
const [currentTime, setCurrentTime] = useState<Date>(new Date())
|
const [currentTime, setCurrentTime] = useState<Date>(new Date())
|
||||||
const [location, setLocation] = useState<string>("Jember, East Java")
|
const [location, setLocation] = useState<string>("Jember, East Java")
|
||||||
|
const [paginationState, setPaginationState] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
const {
|
const { current: map } = useMap()
|
||||||
availableYears,
|
|
||||||
isYearsLoading,
|
|
||||||
crimes,
|
|
||||||
isCrimesLoading,
|
|
||||||
crimesError,
|
|
||||||
selectedYear: hookSelectedYear,
|
|
||||||
selectedMonth: hookSelectedMonth,
|
|
||||||
} = usePrefetchedCrimeData()
|
|
||||||
|
|
||||||
// Use props for selectedYear and selectedMonth if provided, otherwise fall back to hook values
|
|
||||||
const selectedYear = propSelectedYear || hookSelectedYear
|
|
||||||
const selectedMonth = propSelectedMonth || hookSelectedMonth
|
|
||||||
|
|
||||||
// Update current time every minute for the real-time display
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCurrentTime(new Date())
|
setCurrentTime(new Date())
|
||||||
|
@ -98,13 +53,11 @@ export default function CrimeSidebar({
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Format date with selected year and month if provided
|
|
||||||
const getDisplayDate = () => {
|
const getDisplayDate = () => {
|
||||||
// If we have a specific month selected, use that for display
|
|
||||||
if (selectedMonth && selectedMonth !== 'all') {
|
if (selectedMonth && selectedMonth !== 'all') {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
date.setFullYear(selectedYear)
|
date.setFullYear(selectedYear)
|
||||||
date.setMonth(Number(selectedMonth) - 1) // Month is 0-indexed in JS Date
|
date.setMonth(Number(selectedMonth) - 1)
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
@ -112,7 +65,6 @@ export default function CrimeSidebar({
|
||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise show today's date
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
@ -129,33 +81,23 @@ export default function CrimeSidebar({
|
||||||
hour12: true
|
hour12: true
|
||||||
}).format(currentTime)
|
}).format(currentTime)
|
||||||
|
|
||||||
const { data: categoriesData } = useGetCrimeCategories()
|
|
||||||
|
|
||||||
const crimeStats = useMemo(() => {
|
const crimeStats = useMemo(() => {
|
||||||
// Return default values if crimes is undefined, null, or not an array
|
|
||||||
if (!crimes || !Array.isArray(crimes) || crimes.length === 0) return {
|
if (!crimes || !Array.isArray(crimes) || crimes.length === 0) return {
|
||||||
todaysIncidents: 0,
|
todaysIncidents: 0,
|
||||||
totalIncidents: 0,
|
totalIncidents: 0,
|
||||||
recentIncidents: [],
|
recentIncidents: [],
|
||||||
|
filteredIncidents: [],
|
||||||
categoryCounts: {},
|
categoryCounts: {},
|
||||||
districts: {},
|
districts: {},
|
||||||
incidentsByMonth: Array(12).fill(0),
|
incidentsByMonth: Array(12).fill(0),
|
||||||
clearanceRate: 0
|
clearanceRate: 0,
|
||||||
|
incidentsByMonthDetail: {} as Record<string, any[]>,
|
||||||
|
availableMonths: [] as string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure we have a valid array to work with
|
|
||||||
let filteredCrimes = [...crimes]
|
let filteredCrimes = [...crimes]
|
||||||
|
|
||||||
if (selectedCategory !== "all") {
|
const crimeIncidents = filteredCrimes.flatMap((crime: ICrimes) =>
|
||||||
filteredCrimes = crimes.filter((crime: ICrimesProps) =>
|
|
||||||
crime.crime_incidents.some(incident =>
|
|
||||||
incident.crime_categories.name === selectedCategory
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all incidents from all crimes
|
|
||||||
const allIncidents = filteredCrimes.flatMap((crime: ICrimesProps) =>
|
|
||||||
crime.crime_incidents.map(incident => ({
|
crime.crime_incidents.map(incident => ({
|
||||||
id: incident.id,
|
id: incident.id,
|
||||||
timestamp: incident.timestamp,
|
timestamp: incident.timestamp,
|
||||||
|
@ -169,13 +111,13 @@ export default function CrimeSidebar({
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
const totalIncidents = allIncidents.length
|
const totalIncidents = crimeIncidents.length
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const thirtyDaysAgo = new Date()
|
const thirtyDaysAgo = new Date()
|
||||||
thirtyDaysAgo.setDate(today.getDate() - 30)
|
thirtyDaysAgo.setDate(today.getDate() - 30)
|
||||||
|
|
||||||
const recentIncidents = allIncidents
|
const recentIncidents = crimeIncidents
|
||||||
.filter((incident) => {
|
.filter((incident) => {
|
||||||
if (!incident?.timestamp) return false
|
if (!incident?.timestamp) return false
|
||||||
const incidentDate = new Date(incident.timestamp)
|
const incidentDate = new Date(incident.timestamp)
|
||||||
|
@ -187,6 +129,12 @@ export default function CrimeSidebar({
|
||||||
return bTime - aTime
|
return bTime - aTime
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredIncidents = crimeIncidents.sort((a, b) => {
|
||||||
|
const bTime = b?.timestamp ? new Date(b.timestamp).getTime() : 0
|
||||||
|
const aTime = a?.timestamp ? new Date(a.timestamp).getTime() : 0
|
||||||
|
return bTime - aTime
|
||||||
|
})
|
||||||
|
|
||||||
const todaysIncidents = recentIncidents.filter((incident) => {
|
const todaysIncidents = recentIncidents.filter((incident) => {
|
||||||
const incidentDate = incident?.timestamp
|
const incidentDate = incident?.timestamp
|
||||||
? new Date(incident.timestamp)
|
? new Date(incident.timestamp)
|
||||||
|
@ -194,20 +142,20 @@ export default function CrimeSidebar({
|
||||||
return incidentDate.toDateString() === today.toDateString()
|
return incidentDate.toDateString() === today.toDateString()
|
||||||
}).length
|
}).length
|
||||||
|
|
||||||
const categoryCounts = allIncidents.reduce((acc: Record<string, number>, incident) => {
|
const categoryCounts = crimeIncidents.reduce((acc: Record<string, number>, incident) => {
|
||||||
const category = incident?.category || 'Unknown'
|
const category = incident?.category || 'Unknown'
|
||||||
acc[category] = (acc[category] || 0) + 1
|
acc[category] = (acc[category] || 0) + 1
|
||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, number>)
|
}, {} as Record<string, number>)
|
||||||
|
|
||||||
const districts = filteredCrimes.reduce((acc: Record<string, number>, crime: ICrimesProps) => {
|
const districts = filteredCrimes.reduce((acc: Record<string, number>, crime: ICrimes) => {
|
||||||
const districtName = crime.districts.name || 'Unknown'
|
const districtName = crime.districts.name || 'Unknown'
|
||||||
acc[districtName] = (acc[districtName] || 0) + (crime.number_of_crime || 0)
|
acc[districtName] = (acc[districtName] || 0) + (crime.number_of_crime || 0)
|
||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, number>)
|
}, {} as Record<string, number>)
|
||||||
|
|
||||||
const incidentsByMonth = Array(12).fill(0)
|
const incidentsByMonth = Array(12).fill(0)
|
||||||
allIncidents.forEach((incident) => {
|
crimeIncidents.forEach((incident) => {
|
||||||
if (!incident?.timestamp) return;
|
if (!incident?.timestamp) return;
|
||||||
|
|
||||||
const date = new Date(incident.timestamp)
|
const date = new Date(incident.timestamp)
|
||||||
|
@ -217,25 +165,89 @@ export default function CrimeSidebar({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolvedIncidents = allIncidents.filter(incident =>
|
const resolvedIncidents = crimeIncidents.filter(incident =>
|
||||||
incident?.status?.toLowerCase() === "resolved"
|
incident?.status?.toLowerCase() === "resolved"
|
||||||
).length
|
).length
|
||||||
|
|
||||||
const clearanceRate = totalIncidents > 0 ?
|
const clearanceRate = totalIncidents > 0 ?
|
||||||
Math.round((resolvedIncidents / totalIncidents) * 100) : 0
|
Math.round((resolvedIncidents / totalIncidents) * 100) : 0
|
||||||
|
|
||||||
|
const incidentsByMonthDetail: Record<string, any[]> = {}
|
||||||
|
const availableMonths: string[] = []
|
||||||
|
|
||||||
|
crimeIncidents.forEach(incident => {
|
||||||
|
if (!incident?.timestamp) return
|
||||||
|
|
||||||
|
const date = new Date(incident.timestamp)
|
||||||
|
const monthKey = `${date.getFullYear()}-${date.getMonth() + 1}`
|
||||||
|
|
||||||
|
if (!incidentsByMonthDetail[monthKey]) {
|
||||||
|
incidentsByMonthDetail[monthKey] = []
|
||||||
|
availableMonths.push(monthKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
incidentsByMonthDetail[monthKey].push(incident)
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.keys(incidentsByMonthDetail).forEach(monthKey => {
|
||||||
|
incidentsByMonthDetail[monthKey].sort((a, b) => {
|
||||||
|
const bTime = b?.timestamp ? new Date(b.timestamp).getTime() : 0
|
||||||
|
const aTime = a?.timestamp ? new Date(a.timestamp).getTime() : 0
|
||||||
|
return bTime - aTime
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
availableMonths.sort((a, b) => {
|
||||||
|
const [yearA, monthA] = a.split('-').map(Number)
|
||||||
|
const [yearB, monthB] = b.split('-').map(Number)
|
||||||
|
|
||||||
|
if (yearB !== yearA) return yearB - yearA
|
||||||
|
return monthB - monthA
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
todaysIncidents,
|
todaysIncidents,
|
||||||
totalIncidents,
|
totalIncidents,
|
||||||
recentIncidents: recentIncidents.slice(0, 10),
|
recentIncidents: recentIncidents.slice(0, 10),
|
||||||
|
filteredIncidents,
|
||||||
categoryCounts,
|
categoryCounts,
|
||||||
districts,
|
districts,
|
||||||
incidentsByMonth,
|
incidentsByMonth,
|
||||||
clearanceRate
|
clearanceRate,
|
||||||
|
incidentsByMonthDetail,
|
||||||
|
availableMonths
|
||||||
}
|
}
|
||||||
}, [crimes, selectedCategory])
|
}, [crimes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (crimeStats.availableMonths && crimeStats.availableMonths.length > 0) {
|
||||||
|
const initialState: Record<string, number> = {}
|
||||||
|
crimeStats.availableMonths.forEach(month => {
|
||||||
|
initialState[month] = 0
|
||||||
|
})
|
||||||
|
setPaginationState(initialState)
|
||||||
|
}
|
||||||
|
}, [crimeStats.availableMonths])
|
||||||
|
|
||||||
|
const formatMonthKey = (monthKey: string): string => {
|
||||||
|
const [year, month] = monthKey.split('-').map(Number)
|
||||||
|
return `${getMonthName(month)} ${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (monthKey: string, direction: 'next' | 'prev') => {
|
||||||
|
setPaginationState(prev => {
|
||||||
|
const currentPage = prev[monthKey] || 0
|
||||||
|
const totalPages = Math.ceil((crimeStats.incidentsByMonthDetail[monthKey]?.length || 0) / 5)
|
||||||
|
|
||||||
|
if (direction === 'next' && currentPage < totalPages - 1) {
|
||||||
|
return { ...prev, [monthKey]: currentPage + 1 }
|
||||||
|
} else if (direction === 'prev' && currentPage > 0) {
|
||||||
|
return { ...prev, [monthKey]: currentPage - 1 }
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a time period display for the current view
|
|
||||||
const getTimePeriodDisplay = () => {
|
const getTimePeriodDisplay = () => {
|
||||||
if (selectedMonth && selectedMonth !== 'all') {
|
if (selectedMonth && selectedMonth !== 'all') {
|
||||||
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
|
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
|
||||||
|
@ -292,6 +304,30 @@ export default function CrimeSidebar({
|
||||||
return "Low"
|
return "Low"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleIncidentClick = (incident: any) => {
|
||||||
|
if (!map || !incident.longitude || !incident.latitude) return
|
||||||
|
|
||||||
|
map.flyTo({
|
||||||
|
center: [incident.longitude, incident.latitude],
|
||||||
|
zoom: 15,
|
||||||
|
pitch: 60,
|
||||||
|
bearing: 0,
|
||||||
|
duration: 1500,
|
||||||
|
easing: (t) => t * (2 - t),
|
||||||
|
})
|
||||||
|
|
||||||
|
const customEvent = new CustomEvent("incident_click", {
|
||||||
|
detail: incident,
|
||||||
|
bubbles: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (map.getMap().getCanvas()) {
|
||||||
|
map.getMap().getCanvas().dispatchEvent(customEvent)
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(customEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"fixed top-0 left-0 h-full z-40 transition-transform duration-300 ease-in-out bg-background border-r border-sidebar-border",
|
"fixed top-0 left-0 h-full z-40 transition-transform duration-300 ease-in-out bg-background border-r border-sidebar-border",
|
||||||
|
@ -301,7 +337,6 @@ export default function CrimeSidebar({
|
||||||
<div className="relative h-full flex items-stretch">
|
<div className="relative h-full flex items-stretch">
|
||||||
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
|
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
|
||||||
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
|
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
|
||||||
{/* Header with improved styling */}
|
|
||||||
<CardHeader className="p-0 pb-4 shrink-0 relative">
|
<CardHeader className="p-0 pb-4 shrink-0 relative">
|
||||||
<div className="absolute top-0 right-0">
|
<div className="absolute top-0 right-0">
|
||||||
<Button
|
<Button
|
||||||
|
@ -322,7 +357,7 @@ export default function CrimeSidebar({
|
||||||
<CardTitle className="text-xl font-semibold">
|
<CardTitle className="text-xl font-semibold">
|
||||||
Crime Analysis
|
Crime Analysis
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{!isCrimesLoading && (
|
{!isLoading && (
|
||||||
<CardDescription className="text-sm text-sidebar-foreground/70">
|
<CardDescription className="text-sm text-sidebar-foreground/70">
|
||||||
{getTimePeriodDisplay()}
|
{getTimePeriodDisplay()}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
@ -331,7 +366,6 @@ export default function CrimeSidebar({
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Improved tabs with pill style */}
|
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="incidents"
|
defaultValue="incidents"
|
||||||
className="w-full flex-1 flex flex-col overflow-hidden"
|
className="w-full flex-1 flex flex-col overflow-hidden"
|
||||||
|
@ -361,7 +395,7 @@ export default function CrimeSidebar({
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 custom-scrollbar">
|
||||||
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
|
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
|
||||||
{isCrimesLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-24 w-full" />
|
<Skeleton className="h-24 w-full" />
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
@ -377,8 +411,7 @@ export default function CrimeSidebar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 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>
|
||||||
|
@ -408,7 +441,6 @@ export default function CrimeSidebar({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Enhanced stat cards */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<SystemStatusCard
|
<SystemStatusCard
|
||||||
title="Total Cases"
|
title="Total Cases"
|
||||||
|
@ -447,27 +479,49 @@ export default function CrimeSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarSection
|
<Tabs
|
||||||
title={selectedCategory !== "all"
|
defaultValue="recent"
|
||||||
? `${selectedCategory} Incidents`
|
value={activeIncidentTab}
|
||||||
: "Recent Incidents"}
|
onValueChange={setActiveIncidentTab}
|
||||||
icon={<AlertTriangle className="h-4 w-4 text-red-400" />}
|
className="w-full"
|
||||||
>
|
>
|
||||||
{crimeStats.recentIncidents.length === 0 ? (
|
<div className="flex items-center justify-between mb-3">
|
||||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
<h3 className="text-sm font-medium text-sidebar-foreground/90 flex items-center gap-2 pl-1">
|
||||||
<CardContent className="p-4 text-center">
|
<AlertTriangle className="h-4 w-4 text-red-400" />
|
||||||
<div className="flex flex-col items-center gap-2">
|
Incident Reports
|
||||||
<AlertCircle className="h-6 w-6 text-white/40" />
|
</h3>
|
||||||
<p className="text-sm text-white/70">
|
<TabsList className="bg-sidebar-accent p-0.5 rounded-md h-7">
|
||||||
{selectedCategory !== "all"
|
<TabsTrigger
|
||||||
? `No ${selectedCategory} incidents found`
|
value="recent"
|
||||||
: "No recent incidents reported"}
|
className="text-xs px-3 py-0.5 h-6 rounded-sm data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
||||||
</p>
|
>
|
||||||
<p className="text-xs text-white/50">Try adjusting your filters or checking back later</p>
|
Recent
|
||||||
</div>
|
</TabsTrigger>
|
||||||
</CardContent>
|
<TabsTrigger
|
||||||
</Card>
|
value="history"
|
||||||
) : (
|
className="text-xs px-3 py-0.5 h-6 rounded-sm data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="recent" className="m-0 p-0">
|
||||||
|
{crimeStats.recentIncidents.length === 0 ? (
|
||||||
|
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<AlertCircle className="h-6 w-6 text-white/40" />
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
{selectedCategory !== "all"
|
||||||
|
? `No ${selectedCategory} incidents found`
|
||||||
|
: "No recent incidents reported"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/50">Try adjusting your filters or checking back later</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{crimeStats.recentIncidents.slice(0, 6).map((incident) => (
|
{crimeStats.recentIncidents.slice(0, 6).map((incident) => (
|
||||||
<IncidentCard
|
<IncidentCard
|
||||||
|
@ -476,17 +530,117 @@ export default function CrimeSidebar({
|
||||||
time={incident.timestamp ? getTimeAgo(incident.timestamp) : 'Unknown time'}
|
time={incident.timestamp ? getTimeAgo(incident.timestamp) : 'Unknown time'}
|
||||||
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
||||||
severity={getIncidentSeverity(incident)}
|
severity={getIncidentSeverity(incident)}
|
||||||
|
onClick={() => handleIncidentClick(incident)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SidebarSection>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="history" className="m-0 p-0">
|
||||||
|
{crimeStats.availableMonths && crimeStats.availableMonths.length === 0 ? (
|
||||||
|
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileText className="h-6 w-6 text-white/40" />
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
{selectedCategory !== "all"
|
||||||
|
? `No ${selectedCategory} incidents found in the selected period`
|
||||||
|
: "No incidents found in the selected period"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/50">Try adjusting your filters</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-xs text-white/60">
|
||||||
|
Showing incidents from {crimeStats.availableMonths.length} {crimeStats.availableMonths.length === 1 ? 'month' : 'months'}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="h-5 text-[10px]">
|
||||||
|
{selectedCategory !== "all" ? selectedCategory : "All Categories"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{crimeStats.availableMonths.map(monthKey => {
|
||||||
|
const incidents = crimeStats.incidentsByMonthDetail[monthKey] || []
|
||||||
|
const pageSize = 5
|
||||||
|
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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={monthKey} className="mb-5">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3.5 w-3.5 text-amber-400" />
|
||||||
|
<h4 className="font-medium text-xs">{formatMonthKey(monthKey)}</h4>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||||
|
{incidents.length} incident{incidents.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{paginatedIncidents.map((incident) => (
|
||||||
|
<IncidentCard
|
||||||
|
key={incident.id}
|
||||||
|
title={`${incident.category || 'Unknown'} in ${incident.address?.split(',')[0] || 'Unknown Location'}`}
|
||||||
|
time={incident.timestamp ? formatDateString(incident.timestamp) : 'Unknown date'}
|
||||||
|
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
||||||
|
severity={getIncidentSeverity(incident)}
|
||||||
|
onClick={() => handleIncidentClick(incident)}
|
||||||
|
showTimeAgo={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
Page {currentPage + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 py-1 text-[10px]"
|
||||||
|
disabled={currentPage === 0}
|
||||||
|
onClick={() => handlePageChange(monthKey, 'prev')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 py-1 text-[10px]"
|
||||||
|
disabled={currentPage >= totalPages - 1}
|
||||||
|
onClick={() => handlePageChange(monthKey, 'next')}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="statistics" className="m-0 p-0 space-y-4">
|
<TabsContent value="statistics" className="m-0 p-0 space-y-4">
|
||||||
{isCrimesLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-64 w-full" />
|
<Skeleton className="h-64 w-full" />
|
||||||
<Skeleton className="h-24 w-full" />
|
<Skeleton className="h-24 w-full" />
|
||||||
|
@ -795,9 +949,11 @@ interface EnhancedIncidentCardProps {
|
||||||
time: string
|
time: string
|
||||||
location: string
|
location: string
|
||||||
severity: "Low" | "Medium" | "High" | "Critical"
|
severity: "Low" | "Medium" | "High" | "Critical"
|
||||||
|
onClick?: () => void
|
||||||
|
showTimeAgo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function IncidentCard({ title, time, location, severity }: EnhancedIncidentCardProps) {
|
function IncidentCard({ title, time, location, severity, onClick, showTimeAgo = true }: EnhancedIncidentCardProps) {
|
||||||
const getBadgeColor = () => {
|
const getBadgeColor = () => {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case "Low": return "bg-green-500/20 text-green-300";
|
case "Low": return "bg-green-500/20 text-green-300";
|
||||||
|
@ -819,7 +975,10 @@ function IncidentCard({ title, time, location, severity }: EnhancedIncidentCardP
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`bg-white/5 hover:bg-white/10 border-0 text-white shadow-none transition-colors border-l-2 ${getBorderColor()}`}>
|
<Card
|
||||||
|
className={`bg-white/5 hover:bg-white/10 border-0 text-white shadow-none transition-colors border-l-2 ${getBorderColor()} ${onClick ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
<CardContent className="p-3 text-xs">
|
<CardContent className="p-3 text-xs">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||||
|
@ -832,7 +991,16 @@ function IncidentCard({ title, time, location, severity }: EnhancedIncidentCardP
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
<span>{location}</span>
|
<span>{location}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-white/60">{time}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
Loading…
Reference in New Issue