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:
vergiLgood1 2025-05-04 04:24:17 +07:00
parent 593b198b94
commit 0de70f9057
12 changed files with 1056 additions and 876 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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