feat: add incident severity categorization and utility functions
- Implemented `getIncidentSeverity` function to categorize incidents based on predefined categories. - Added `formatMonthKey` function to format month keys for display. - Introduced `getTimeAgo` function to calculate and format time elapsed since a given timestamp. feat: create custom hooks and components for crime analytics - Developed `useCrimeAnalytics` hook to process and analyze crime data, providing insights such as total incidents, recent incidents, and category counts. - Created `CrimeTypeCard`, `IncidentCard`, `StatCard`, and `SystemStatusCard` components for displaying crime statistics and incident details in the sidebar. - Implemented `SidebarIncidentsTab` and `SidebarStatisticsTab` components to manage and display incident data and statistics. feat: enhance sidebar functionality with info and statistics tabs - Added `SidebarInfoTab` and `SidebarStatisticsTab` components to provide users with information about crime severity, map markers, and overall crime statistics. - Integrated pagination functionality with `usePagination` hook to manage incident history navigation. style: improve UI components with consistent styling and layout adjustments - Updated card components and layout for better visual hierarchy and user experience. - Ensured responsive design and accessibility across all new components.
This commit is contained in:
parent
593b198b94
commit
0de70f9057
|
@ -0,0 +1,152 @@
|
|||
import { useMemo } from 'react';
|
||||
import { ICrimes } from '@/app/_utils/types/crimes';
|
||||
|
||||
export function useCrimeAnalytics(crimes: ICrimes[]) {
|
||||
return useMemo(() => {
|
||||
if (!crimes || !Array.isArray(crimes) || crimes.length === 0)
|
||||
return {
|
||||
todaysIncidents: 0,
|
||||
totalIncidents: 0,
|
||||
recentIncidents: [],
|
||||
filteredIncidents: [],
|
||||
categoryCounts: {},
|
||||
districts: {},
|
||||
incidentsByMonth: Array(12).fill(0),
|
||||
clearanceRate: 0,
|
||||
incidentsByMonthDetail: {} as Record<string, any[]>,
|
||||
availableMonths: [] as string[],
|
||||
};
|
||||
|
||||
let filteredCrimes = [...crimes];
|
||||
|
||||
const crimeIncidents = filteredCrimes.flatMap((crime: ICrimes) =>
|
||||
crime.crime_incidents.map((incident) => ({
|
||||
id: incident.id,
|
||||
timestamp: incident.timestamp,
|
||||
description: incident.description,
|
||||
status: incident.status,
|
||||
category: incident.crime_categories.name,
|
||||
type: incident.crime_categories.type,
|
||||
address: incident.locations.address,
|
||||
latitude: incident.locations.latitude,
|
||||
longitude: incident.locations.longitude,
|
||||
}))
|
||||
);
|
||||
|
||||
const totalIncidents = crimeIncidents.length;
|
||||
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
const recentIncidents = crimeIncidents
|
||||
.filter((incident) => {
|
||||
if (!incident?.timestamp) return false;
|
||||
const incidentDate = new Date(incident.timestamp);
|
||||
return incidentDate >= thirtyDaysAgo;
|
||||
})
|
||||
.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 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 incidentDate = incident?.timestamp
|
||||
? new Date(incident.timestamp)
|
||||
: new Date(0);
|
||||
return incidentDate.toDateString() === today.toDateString();
|
||||
}).length;
|
||||
|
||||
const categoryCounts = crimeIncidents.reduce(
|
||||
(acc: Record<string, number>, incident) => {
|
||||
const category = incident?.category || 'Unknown';
|
||||
acc[category] = (acc[category] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const districts = filteredCrimes.reduce(
|
||||
(acc: Record<string, number>, crime: ICrimes) => {
|
||||
const districtName = crime.districts.name || 'Unknown';
|
||||
acc[districtName] =
|
||||
(acc[districtName] || 0) + (crime.number_of_crime || 0);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const incidentsByMonth = Array(12).fill(0);
|
||||
crimeIncidents.forEach((incident) => {
|
||||
if (!incident?.timestamp) return;
|
||||
|
||||
const date = new Date(incident.timestamp);
|
||||
const month = date.getMonth();
|
||||
if (month >= 0 && month < 12) {
|
||||
incidentsByMonth[month]++;
|
||||
}
|
||||
});
|
||||
|
||||
const resolvedIncidents = crimeIncidents.filter(
|
||||
(incident) => incident?.status?.toLowerCase() === 'resolved'
|
||||
).length;
|
||||
|
||||
const clearanceRate =
|
||||
totalIncidents > 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 {
|
||||
todaysIncidents,
|
||||
totalIncidents,
|
||||
recentIncidents: recentIncidents.slice(0, 10),
|
||||
filteredIncidents,
|
||||
categoryCounts,
|
||||
districts,
|
||||
incidentsByMonth,
|
||||
clearanceRate,
|
||||
incidentsByMonthDetail,
|
||||
availableMonths,
|
||||
};
|
||||
}, [crimes]);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react'
|
||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||
|
||||
interface CrimeTypeCardProps {
|
||||
type: string
|
||||
count: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export function CrimeTypeCard({ type, count, percentage }: CrimeTypeCardProps) {
|
||||
return (
|
||||
<Card className="bg-white/5 hover:bg-white/10 border-0 text-white shadow-none transition-colors">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">{type}</span>
|
||||
<span className="text-sm text-white/70">{count} cases</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-primary to-primary/80 h-full rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-white/70 text-right">{percentage}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react'
|
||||
import { AlertTriangle, MapPin, Calendar } from 'lucide-react'
|
||||
import { Badge } from "@/app/_components/ui/badge"
|
||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||
|
||||
interface EnhancedIncidentCardProps {
|
||||
title: string
|
||||
time: string
|
||||
location: string
|
||||
severity: "Low" | "Medium" | "High" | "Critical"
|
||||
onClick?: () => void
|
||||
showTimeAgo?: boolean
|
||||
}
|
||||
|
||||
export function IncidentCard({
|
||||
title,
|
||||
time,
|
||||
location,
|
||||
severity,
|
||||
onClick,
|
||||
showTimeAgo = true
|
||||
}: EnhancedIncidentCardProps) {
|
||||
const getBadgeColor = () => {
|
||||
switch (severity) {
|
||||
case "Low": return "bg-green-500/20 text-green-300";
|
||||
case "Medium": return "bg-yellow-500/20 text-yellow-300";
|
||||
case "High": return "bg-orange-500/20 text-orange-300";
|
||||
case "Critical": return "bg-red-500/20 text-red-300";
|
||||
default: return "bg-gray-500/20 text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderColor = () => {
|
||||
switch (severity) {
|
||||
case "Low": return "border-l-green-500";
|
||||
case "Medium": return "border-l-yellow-500";
|
||||
case "High": return "border-l-orange-500";
|
||||
case "Critical": return "border-l-red-500";
|
||||
default: return "border-l-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium">{title}</p>
|
||||
<Badge className={`${getBadgeColor()} text-[9px] h-4 ml-1`}>{severity}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-white/60">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{location}</span>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react'
|
||||
|
||||
interface SidebarSectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SidebarSection({ title, children, icon }: SidebarSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-sidebar-foreground/90 mb-3 flex items-center gap-2 pl-1">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react'
|
||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string
|
||||
change: string
|
||||
isPositive?: boolean
|
||||
icon?: React.ReactNode
|
||||
bgColor?: string
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
isPositive = false,
|
||||
icon,
|
||||
bgColor = "bg-white/10"
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Card className={`${bgColor} hover:bg-white/15 border-0 text-white shadow-none transition-colors`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-white/70 flex items-center gap-1.5">
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
<span className={`text-xs ${isPositive ? "text-green-400" : "text-amber-400"}`}>{change}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-1.5">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react'
|
||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||
|
||||
interface SystemStatusCardProps {
|
||||
title: string
|
||||
status: string
|
||||
statusIcon: React.ReactNode
|
||||
statusColor: string
|
||||
updatedTime?: string
|
||||
bgColor?: string
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
export function SystemStatusCard({
|
||||
title,
|
||||
status,
|
||||
statusIcon,
|
||||
statusColor,
|
||||
updatedTime,
|
||||
bgColor = "bg-sidebar-accent/20",
|
||||
borderColor = "border-sidebar-border"
|
||||
}: SystemStatusCardProps) {
|
||||
return (
|
||||
<Card className={`${bgColor} border ${borderColor} hover:border-sidebar-border/80 transition-colors`}>
|
||||
<CardContent className="p-3 text-xs">
|
||||
<div className="font-medium mb-1.5">{title}</div>
|
||||
<div className={`flex items-center gap-1.5 ${statusColor} text-base font-semibold`}>
|
||||
{statusIcon}
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
{updatedTime && (
|
||||
<div className="text-sidebar-foreground/50 text-[10px] mt-1.5">{updatedTime}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,285 @@
|
|||
import React from 'react'
|
||||
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar } 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 } from "../components/incident-card"
|
||||
|
||||
interface SidebarIncidentsTabProps {
|
||||
crimeStats: any
|
||||
formattedDate: string
|
||||
formattedTime: string
|
||||
location: string
|
||||
selectedMonth?: number | "all"
|
||||
selectedYear: number
|
||||
selectedCategory: string | "all"
|
||||
getTimePeriodDisplay: () => string
|
||||
paginationState: Record<string, number>
|
||||
handlePageChange: (monthKey: string, direction: 'next' | 'prev') => void
|
||||
handleIncidentClick: (incident: any) => void
|
||||
activeIncidentTab: string
|
||||
setActiveIncidentTab: (tab: string) => void
|
||||
}
|
||||
|
||||
export function SidebarIncidentsTab({
|
||||
crimeStats,
|
||||
formattedDate,
|
||||
formattedTime,
|
||||
location,
|
||||
selectedMonth = "all",
|
||||
selectedYear,
|
||||
selectedCategory,
|
||||
getTimePeriodDisplay,
|
||||
paginationState,
|
||||
handlePageChange,
|
||||
handleIncidentClick,
|
||||
activeIncidentTab,
|
||||
setActiveIncidentTab
|
||||
}: SidebarIncidentsTabProps) {
|
||||
const topCategories = crimeStats.categoryCounts ?
|
||||
Object.entries(crimeStats.categoryCounts)
|
||||
.sort((a: any, b: any) => (b[1] as number) - (a[1] as number))
|
||||
.slice(0, 4)
|
||||
.map(([type, count]: [string, unknown]) => {
|
||||
const countAsNumber = count as number;
|
||||
const percentage = Math.round(((countAsNumber) / crimeStats.totalIncidents) * 100) || 0
|
||||
return { type, count: countAsNumber, percentage }
|
||||
}) : []
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Enhanced info card */}
|
||||
<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">
|
||||
<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 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 gap-2">
|
||||
<Calendar className="h-4 w-4 text-sidebar-primary" />
|
||||
<span className="font-medium">{formattedDate}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-sidebar-primary" />
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MapPin className="h-4 w-4 text-sidebar-primary" />
|
||||
<span className="text-sidebar-foreground/70">{location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400" />
|
||||
<span>
|
||||
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
|
||||
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Enhanced stat cards */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SystemStatusCard
|
||||
title="Total Cases"
|
||||
status={`${crimeStats?.totalIncidents || 0}`}
|
||||
statusIcon={<AlertCircle className="h-4 w-4 text-green-400" />}
|
||||
statusColor="text-green-400"
|
||||
updatedTime={getTimePeriodDisplay()}
|
||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||
borderColor="border-sidebar-border"
|
||||
/>
|
||||
<SystemStatusCard
|
||||
title="Recent Cases"
|
||||
status={`${crimeStats?.recentIncidents?.length || 0}`}
|
||||
statusIcon={<Clock className="h-4 w-4 text-amber-400" />}
|
||||
statusColor="text-amber-400"
|
||||
updatedTime="Last 30 days"
|
||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||
borderColor="border-sidebar-border"
|
||||
/>
|
||||
<SystemStatusCard
|
||||
title="Top Category"
|
||||
status={topCategories.length > 0 ? topCategories[0].type : "None"}
|
||||
statusIcon={<Shield className="h-4 w-4 text-green-400" />}
|
||||
statusColor="text-green-400"
|
||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||
borderColor="border-sidebar-border"
|
||||
/>
|
||||
<SystemStatusCard
|
||||
title="Districts"
|
||||
status={`${Object.keys(crimeStats.districts).length}`}
|
||||
statusIcon={<MapPin className="h-4 w-4 text-purple-400" />}
|
||||
statusColor="text-purple-400"
|
||||
updatedTime="Affected areas"
|
||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||
borderColor="border-sidebar-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nested tabs for Recent and History */}
|
||||
<Tabs
|
||||
defaultValue="recent"
|
||||
value={activeIncidentTab}
|
||||
onValueChange={setActiveIncidentTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-sidebar-foreground/90 flex items-center gap-2 pl-1">
|
||||
<AlertTriangle className="h-4 w-4 text-red-400" />
|
||||
Incident Reports
|
||||
</h3>
|
||||
<TabsList className="bg-sidebar-accent p-0.5 rounded-md h-7">
|
||||
<TabsTrigger
|
||||
value="recent"
|
||||
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"
|
||||
>
|
||||
Recent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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>
|
||||
|
||||
{/* Recent Incidents Tab Content */}
|
||||
<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">
|
||||
{crimeStats.recentIncidents.slice(0, 6).map((incident: any) => (
|
||||
<IncidentCard
|
||||
key={incident.id}
|
||||
title={`${incident.category || 'Unknown'} in ${incident.address?.split(',')[0] || 'Unknown Location'}`}
|
||||
time={incident.timestamp ? getTimeAgo(incident.timestamp) : 'Unknown time'}
|
||||
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
||||
severity={getIncidentSeverity(incident)}
|
||||
onClick={() => handleIncidentClick(incident)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* History Incidents Tab Content */}
|
||||
<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: string) => {
|
||||
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: any) => (
|
||||
<IncidentCard
|
||||
key={incident.id}
|
||||
title={`${incident.category || 'Unknown'} in ${incident.address?.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}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
import React from 'react'
|
||||
import { Layers, Info, Eye, Filter, MapPin, AlertTriangle, AlertCircle } from 'lucide-react'
|
||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||
import { Separator } from "@/app/_components/ui/separator"
|
||||
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
|
||||
import { SidebarSection } from "../components/sidebar-section"
|
||||
|
||||
export function SidebarInfoTab() {
|
||||
return (
|
||||
<>
|
||||
<SidebarSection title="Map Legend" icon={<Layers className="h-4 w-4 text-green-400" />}>
|
||||
<Card className="bg-gradient-to-r from-zinc-800/80 to-zinc-900/80 border border-white/10">
|
||||
<CardContent className="p-4 text-xs space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium mb-2 text-sm">Crime Severity</h4>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: CRIME_RATE_COLORS.low }}></div>
|
||||
<span>Low Crime Rate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: CRIME_RATE_COLORS.medium }}></div>
|
||||
<span>Medium Crime Rate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: CRIME_RATE_COLORS.high }}></div>
|
||||
<span>High Crime Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-white/20 my-3" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium mb-2 text-sm">Map Markers</h4>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span>Individual Incident</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<div className="w-5 h-5 rounded-full bg-pink-400 flex items-center justify-center text-[10px] text-white">5</div>
|
||||
<span className="font-bold">Incident Cluster</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection title="About" icon={<Info className="h-4 w-4 text-green-400" />}>
|
||||
<Card className="bg-gradient-to-r from-zinc-800/80 to-zinc-900/80 border border-white/10">
|
||||
<CardContent className="p-4 text-xs">
|
||||
<p className="mb-3">
|
||||
SIGAP Crime Map provides real-time visualization and analysis
|
||||
of crime incidents across Jember region.
|
||||
</p>
|
||||
<p>
|
||||
Data is sourced from official police reports and updated
|
||||
daily to ensure accurate information.
|
||||
</p>
|
||||
<div className="mt-3 p-2 bg-white/5 rounded-lg text-white/60">
|
||||
<div className="flex justify-between">
|
||||
<span>Version</span>
|
||||
<span className="font-medium">1.2.4</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span>Last Updated</span>
|
||||
<span className="font-medium">June 18, 2024</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection title="How to Use" icon={<Eye className="h-4 w-4 text-green-400" />}>
|
||||
<Card className="bg-gradient-to-r from-zinc-800/80 to-zinc-900/80 border border-white/10">
|
||||
<CardContent className="p-4 text-xs space-y-3">
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="bg-emerald-900/50 p-1.5 rounded-md">
|
||||
<Filter className="h-3.5 w-3.5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Filtering</span>
|
||||
<p className="text-white/70 mt-1">
|
||||
Use the year, month, and category filters at the top to
|
||||
refine the data shown on the map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="bg-emerald-900/50 p-1.5 rounded-md">
|
||||
<MapPin className="h-3.5 w-3.5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">District Information</span>
|
||||
<p className="text-white/70 mt-1">
|
||||
Click on any district to view detailed crime statistics for that area.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="bg-emerald-900/50 p-1.5 rounded-md">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Incidents</span>
|
||||
<p className="text-white/70 mt-1">
|
||||
Click on incident markers to view details about specific crime reports.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SidebarSection>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
import React from 'react'
|
||||
import { Activity, Calendar, CheckCircle, AlertTriangle, LineChart, PieChart, FileText } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card"
|
||||
import { Separator } from "@/app/_components/ui/separator"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
import { getMonthName } from "@/app/_utils/common"
|
||||
import { SidebarSection } from "../components/sidebar-section"
|
||||
import { StatCard } from "../components/stat-card"
|
||||
import { CrimeTypeCard } from "../components/crime-type-card"
|
||||
|
||||
interface SidebarStatisticsTabProps {
|
||||
crimeStats: any
|
||||
selectedMonth?: number | "all"
|
||||
selectedYear: number
|
||||
}
|
||||
|
||||
export function SidebarStatisticsTab({
|
||||
crimeStats,
|
||||
selectedMonth = "all",
|
||||
selectedYear
|
||||
}: SidebarStatisticsTabProps) {
|
||||
const topCategories = crimeStats.categoryCounts ?
|
||||
Object.entries(crimeStats.categoryCounts)
|
||||
.sort((a: any, b: any) => (b[1] as number) - (a[1] as number))
|
||||
.slice(0, 4)
|
||||
.map(([type, count]: [string, unknown]) => {
|
||||
const countAsNumber = count as number;
|
||||
const percentage = Math.round(((countAsNumber) / crimeStats.totalIncidents) * 100) || 0
|
||||
return { type, count: countAsNumber, percentage }
|
||||
}) : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden">
|
||||
<CardHeader className="p-3 pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<LineChart className="h-4 w-4 text-green-400" />
|
||||
Monthly Incidents
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-white/60">{selectedYear}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
<div className="h-32 flex items-end gap-1 mt-2">
|
||||
{crimeStats.incidentsByMonth.map((count: number, i: number) => {
|
||||
const maxCount = Math.max(...crimeStats.incidentsByMonth)
|
||||
const height = maxCount > 0 ? (count / maxCount) * 100 : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-gradient-to-t from-emerald-600 to-green-400 w-full rounded-t-md",
|
||||
selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "from-amber-500 to-amber-400" : ""
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(5, height)}%`,
|
||||
opacity: 0.7 + (i / 24)
|
||||
}}
|
||||
title={`${getMonthName(i + 1)}: ${count} incidents`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-[10px] text-white/60">
|
||||
<span>Jan</span>
|
||||
<span>Feb</span>
|
||||
<span>Mar</span>
|
||||
<span>Apr</span>
|
||||
<span>May</span>
|
||||
<span>Jun</span>
|
||||
<span>Jul</span>
|
||||
<span>Aug</span>
|
||||
<span>Sep</span>
|
||||
<span>Oct</span>
|
||||
<span>Nov</span>
|
||||
<span>Dec</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SidebarSection title="Crime Overview" icon={<Activity className="h-4 w-4 text-blue-400" />}>
|
||||
<div className="space-y-3">
|
||||
<StatCard
|
||||
title="Total Incidents"
|
||||
value={crimeStats.totalIncidents.toString()}
|
||||
change={`${Object.keys(crimeStats.districts).length} districts`}
|
||||
icon={<AlertTriangle className="h-4 w-4 text-blue-400" />}
|
||||
bgColor="bg-gradient-to-r from-blue-900/30 to-blue-800/20"
|
||||
/>
|
||||
<StatCard
|
||||
title={selectedMonth !== 'all' ?
|
||||
`${getMonthName(Number(selectedMonth))} Cases` :
|
||||
"Monthly Average"}
|
||||
value={selectedMonth !== 'all' ?
|
||||
crimeStats.totalIncidents.toString() :
|
||||
Math.round(crimeStats.totalIncidents /
|
||||
(crimeStats.incidentsByMonth.filter((c: number) => c > 0).length || 1)
|
||||
).toString()}
|
||||
change={selectedMonth !== 'all' ?
|
||||
`in ${getMonthName(Number(selectedMonth))}` :
|
||||
"per active month"}
|
||||
isPositive={false}
|
||||
icon={<Calendar className="h-4 w-4 text-amber-400" />}
|
||||
bgColor="bg-gradient-to-r from-amber-900/30 to-amber-800/20"
|
||||
/>
|
||||
<StatCard
|
||||
title="Clearance Rate"
|
||||
value={`${crimeStats.clearanceRate}%`}
|
||||
change="of cases resolved"
|
||||
isPositive={crimeStats.clearanceRate > 50}
|
||||
icon={<CheckCircle className="h-4 w-4 text-green-400" />}
|
||||
bgColor="bg-gradient-to-r from-green-900/30 to-green-800/20"
|
||||
/>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
|
||||
<Separator className="bg-white/20 my-4" />
|
||||
|
||||
<SidebarSection title="Most Common Crimes" icon={<PieChart className="h-4 w-4 text-amber-400" />}>
|
||||
<div className="space-y-3">
|
||||
{topCategories.length > 0 ? (
|
||||
topCategories.map((category: any) => (
|
||||
<CrimeTypeCard
|
||||
key={category.type}
|
||||
type={category.type}
|
||||
count={category.count}
|
||||
percentage={category.percentage}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<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">No crime data available</p>
|
||||
<p className="text-xs text-white/50">Try selecting a different time period</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function usePagination(availableMonths: string[]) {
|
||||
const [paginationState, setPaginationState] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (availableMonths && availableMonths.length > 0) {
|
||||
const initialState: Record<string, number> = {};
|
||||
availableMonths.forEach((month) => {
|
||||
initialState[month] = 0; // Start at page 0 for each month
|
||||
});
|
||||
setPaginationState(initialState);
|
||||
}
|
||||
}, [availableMonths]);
|
||||
|
||||
// Pagination handler for a specific month
|
||||
const handlePageChange = (monthKey: string, direction: 'next' | 'prev') => {
|
||||
setPaginationState((prev) => {
|
||||
const currentPage = prev[monthKey] || 0;
|
||||
|
||||
if (direction === 'next') {
|
||||
return { ...prev, [monthKey]: currentPage + 1 };
|
||||
} else if (direction === 'prev' && currentPage > 0) {
|
||||
return { ...prev, [monthKey]: currentPage - 1 };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
return { paginationState, handlePageChange };
|
||||
}
|
|
@ -789,3 +789,59 @@ export function formatNumber(num?: number): string {
|
|||
// Otherwise, format with commas
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export function getIncidentSeverity(
|
||||
incident: any
|
||||
): 'Low' | 'Medium' | 'High' | 'Critical' {
|
||||
if (!incident) return 'Low';
|
||||
|
||||
const category = incident.category || 'Unknown';
|
||||
|
||||
const highSeverityCategories = [
|
||||
'Pembunuhan',
|
||||
'Perkosaan',
|
||||
'Penculikan',
|
||||
'Lahgun Senpi/Handak/Sajam',
|
||||
'PTPPO',
|
||||
'Trafficking In Person',
|
||||
];
|
||||
|
||||
const mediumSeverityCategories = [
|
||||
'Penganiayaan Berat',
|
||||
'Penganiayaan Ringan',
|
||||
'Pencurian Biasa',
|
||||
'Curat',
|
||||
'Curas',
|
||||
'Curanmor',
|
||||
'Pengeroyokan',
|
||||
'PKDRT',
|
||||
'Penggelapan',
|
||||
'Pengrusakan',
|
||||
];
|
||||
|
||||
if (highSeverityCategories.includes(category)) return 'High';
|
||||
if (mediumSeverityCategories.includes(category)) return 'Medium';
|
||||
|
||||
if (incident.type === 'Pidana Tertentu') return 'Medium';
|
||||
return 'Low';
|
||||
}
|
||||
|
||||
export function formatMonthKey(monthKey: string): string {
|
||||
const [year, month] = monthKey.split('-').map(Number);
|
||||
return `${getMonthName(month)} ${year}`;
|
||||
}
|
||||
|
||||
export function getTimeAgo(timestamp: string | Date) {
|
||||
const now = new Date();
|
||||
const eventTime = new Date(timestamp);
|
||||
const diffMs = now.getTime() - eventTime.getTime();
|
||||
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
return 'just now';
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue