From 0de70f9057d66f916ff0be81f2b57b7a62fa75f5 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Sun, 4 May 2025 04:24:17 +0700 Subject: [PATCH] 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. --- .../_hooks/use-crime-analytics.ts | 152 +++ .../sidebar/components/crime-type-card.tsx | 28 + .../map/sidebar/components/incident-card.tsx | 75 ++ .../sidebar/components/sidebar-section.tsx | 19 + .../map/sidebar/components/stat-card.tsx | 35 + .../sidebar/components/system-status-card.tsx | 37 + .../_components/map/sidebar/map-sidebar.tsx | 951 ++---------------- .../map/sidebar/tabs/incidents-tab.tsx | 285 ++++++ .../_components/map/sidebar/tabs/info-tab.tsx | 116 +++ .../map/sidebar/tabs/statistics-tab.tsx | 145 +++ sigap-website/app/_hooks/use-pagination.ts | 33 + sigap-website/app/_utils/common.ts | 56 ++ 12 files changed, 1056 insertions(+), 876 deletions(-) create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics.ts create mode 100644 sigap-website/app/_components/map/sidebar/components/crime-type-card.tsx create mode 100644 sigap-website/app/_components/map/sidebar/components/incident-card.tsx create mode 100644 sigap-website/app/_components/map/sidebar/components/sidebar-section.tsx create mode 100644 sigap-website/app/_components/map/sidebar/components/stat-card.tsx create mode 100644 sigap-website/app/_components/map/sidebar/components/system-status-card.tsx create mode 100644 sigap-website/app/_components/map/sidebar/tabs/incidents-tab.tsx create mode 100644 sigap-website/app/_components/map/sidebar/tabs/info-tab.tsx create mode 100644 sigap-website/app/_components/map/sidebar/tabs/statistics-tab.tsx create mode 100644 sigap-website/app/_hooks/use-pagination.ts diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics.ts new file mode 100644 index 0000000..b2cfb19 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics.ts @@ -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, + 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, incident) => { + const category = incident?.category || 'Unknown'; + acc[category] = (acc[category] || 0) + 1; + return acc; + }, + {} as Record + ); + + const districts = filteredCrimes.reduce( + (acc: Record, crime: ICrimes) => { + const districtName = crime.districts.name || 'Unknown'; + acc[districtName] = + (acc[districtName] || 0) + (crime.number_of_crime || 0); + return acc; + }, + {} as Record + ); + + 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 = {}; + 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]); +} diff --git a/sigap-website/app/_components/map/sidebar/components/crime-type-card.tsx b/sigap-website/app/_components/map/sidebar/components/crime-type-card.tsx new file mode 100644 index 0000000..f4920af --- /dev/null +++ b/sigap-website/app/_components/map/sidebar/components/crime-type-card.tsx @@ -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 ( + + +
+ {type} + {count} cases +
+
+
+
+
{percentage}%
+
+
+ ) +} diff --git a/sigap-website/app/_components/map/sidebar/components/incident-card.tsx b/sigap-website/app/_components/map/sidebar/components/incident-card.tsx new file mode 100644 index 0000000..8e89343 --- /dev/null +++ b/sigap-website/app/_components/map/sidebar/components/incident-card.tsx @@ -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 ( + + +
+ +
+
+

{title}

+ {severity} +
+
+ + {location} +
+
+ {showTimeAgo ? ( + time + ) : ( + <> + + {time} + + )} +
+
+
+
+
+ ); +} diff --git a/sigap-website/app/_components/map/sidebar/components/sidebar-section.tsx b/sigap-website/app/_components/map/sidebar/components/sidebar-section.tsx new file mode 100644 index 0000000..1a53291 --- /dev/null +++ b/sigap-website/app/_components/map/sidebar/components/sidebar-section.tsx @@ -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 ( +
+

+ {icon} + {title} +

+ {children} +
+ ) +} diff --git a/sigap-website/app/_components/map/sidebar/components/stat-card.tsx b/sigap-website/app/_components/map/sidebar/components/stat-card.tsx new file mode 100644 index 0000000..8dd2096 --- /dev/null +++ b/sigap-website/app/_components/map/sidebar/components/stat-card.tsx @@ -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 ( + + +
+ + {icon} + {title} + + {change} +
+
{value}
+
+
+ ) +} diff --git a/sigap-website/app/_components/map/sidebar/components/system-status-card.tsx b/sigap-website/app/_components/map/sidebar/components/system-status-card.tsx new file mode 100644 index 0000000..ac6d376 --- /dev/null +++ b/sigap-website/app/_components/map/sidebar/components/system-status-card.tsx @@ -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 ( + + +
{title}
+
+ {statusIcon} + {status} +
+ {updatedTime && ( +
{updatedTime}
+ )} +
+
+ ) +} diff --git a/sigap-website/app/_components/map/sidebar/map-sidebar.tsx b/sigap-website/app/_components/map/sidebar/map-sidebar.tsx index 3ee96d7..3c99293 100644 --- a/sigap-website/app/_components/map/sidebar/map-sidebar.tsx +++ b/sigap-website/app/_components/map/sidebar/map-sidebar.tsx @@ -1,22 +1,24 @@ "use client" -import React, { useState, useEffect, useMemo } from "react" -import { AlertTriangle, BarChart, ChevronRight, MapPin, Skull, Shield, FileText, Clock, Calendar, MapIcon, Info, CheckCircle, AlertCircle, XCircle, Bell, Users, Search, List, RefreshCw, Eye, X, ChevronLeft, PieChart, LineChart, Activity, Filter, Layers, Settings, Menu } from 'lucide-react' -import { Separator } from "@/app/_components/ui/separator" -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card" +import React, { useState, useEffect } from "react" +import { AlertTriangle, Calendar, Clock, MapPin, X, ChevronRight } from 'lucide-react' +import { Card, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card" import { cn } from "@/app/_lib/utils" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs" -import { Badge } from "@/app/_components/ui/badge" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/app/_components/ui/tabs" import { Button } from "@/app/_components/ui/button" -import { CRIME_RATE_COLORS } from "@/app/_utils/const/map" -import { usePrefetchedCrimeData } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes" -import { getMonthName, formatDateString } from "@/app/_utils/common" import { Skeleton } from "@/app/_components/ui/skeleton" -import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries" -import { $Enums } from "@prisma/client" import { useMap } from "react-map-gl/mapbox" import { ICrimes } from "@/app/_utils/types/crimes" +// Import sidebar components +import { SidebarIncidentsTab } from "./tabs/incidents-tab" + +import { useCrimeAnalytics } from "../../../(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics" +import { usePagination } from "../../../_hooks/use-pagination" +import { getMonthName } from "@/app/_utils/common" +import { SidebarInfoTab } from "./tabs/info-tab" +import { SidebarStatisticsTab } from "./tabs/statistics-tab" + interface CrimeSidebarProps { className?: string defaultCollapsed?: boolean @@ -41,10 +43,15 @@ export default function CrimeSidebar({ const [activeIncidentTab, setActiveIncidentTab] = useState("recent") const [currentTime, setCurrentTime] = useState(new Date()) const [location, setLocation] = useState("Jember, East Java") - const [paginationState, setPaginationState] = useState>({}) + // Get the map instance to use for flyTo const { current: map } = useMap() + // Use custom hooks for analytics and pagination + const crimeStats = useCrimeAnalytics(crimes) + const { paginationState, handlePageChange } = usePagination(crimeStats.availableMonths) + + // Update current time every minute for the real-time display useEffect(() => { const timer = setInterval(() => { setCurrentTime(new Date()) @@ -53,11 +60,13 @@ export default function CrimeSidebar({ return () => clearInterval(timer) }, []) + // Format date with selected year and month if provided const getDisplayDate = () => { + // If we have a specific month selected, use that for display if (selectedMonth && selectedMonth !== 'all') { const date = new Date() date.setFullYear(selectedYear) - date.setMonth(Number(selectedMonth) - 1) + date.setMonth(Number(selectedMonth) - 1) // Month is 0-indexed in JS Date return new Intl.DateTimeFormat('en-US', { year: 'numeric', @@ -65,6 +74,7 @@ export default function CrimeSidebar({ }).format(date) } + // Otherwise show today's date return new Intl.DateTimeFormat('en-US', { weekday: 'long', year: 'numeric', @@ -81,173 +91,7 @@ export default function CrimeSidebar({ hour12: true }).format(currentTime) - const crimeStats = 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, - 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, incident) => { - const category = incident?.category || 'Unknown' - acc[category] = (acc[category] || 0) + 1 - return acc - }, {} as Record) - - const districts = filteredCrimes.reduce((acc: Record, crime: ICrimes) => { - const districtName = crime.districts.name || 'Unknown' - acc[districtName] = (acc[districtName] || 0) + (crime.number_of_crime || 0) - return acc - }, {} as Record) - - 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 = {} - 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]) - - useEffect(() => { - if (crimeStats.availableMonths && crimeStats.availableMonths.length > 0) { - const initialState: Record = {} - 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 = () => { if (selectedMonth && selectedMonth !== 'all') { return `${getMonthName(Number(selectedMonth))} ${selectedYear}` @@ -255,67 +99,21 @@ export default function CrimeSidebar({ return `${selectedYear} - All months` } - const topCategories = useMemo(() => { - if (!crimeStats.categoryCounts) return [] - - return Object.entries(crimeStats.categoryCounts) - .sort((a, b) => (b[1] as number) - (a[1] as number)) - .slice(0, 4) - .map(([type, count]) => { - const percentage = Math.round(((count as number) / crimeStats.totalIncidents) * 100) || 0 - return { type, count: count as number, percentage } - }) - }, [crimeStats]) - - const 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' - } - - const 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" - } - + // Function to fly to incident location when clicked const handleIncidentClick = (incident: any) => { if (!map || !incident.longitude || !incident.latitude) return + // Fly to the incident location map.flyTo({ center: [incident.longitude, incident.latitude], zoom: 15, - pitch: 60, + pitch: 0, bearing: 0, duration: 1500, - easing: (t) => t * (2 - t), + easing: (t) => t * (2 - t), // easeOutQuad }) + // Create and dispatch a custom event for the incident click const customEvent = new CustomEvent("incident_click", { detail: incident, bubbles: true @@ -337,6 +135,7 @@ export default function CrimeSidebar({
+ {/* Header with improved styling */}
- -
-
- )} -
- ) - })} -
- )} - - - - )} - - - {isLoading ? ( -
- - - - +
+ + +
- ) : ( - <> - - - - - Monthly Incidents - - {selectedYear} - - -
- {crimeStats.incidentsByMonth.map((count, i) => { - const maxCount = Math.max(...crimeStats.incidentsByMonth) - const height = maxCount > 0 ? (count / maxCount) * 100 : 0 +
+ ) : ( + <> + + + - return ( -
- ) - })} -
-
- Jan - Feb - Mar - Apr - May - Jun - Jul - Aug - Sep - Oct - Nov - Dec -
-
-
+ + + - }> -
- } - bgColor="bg-gradient-to-r from-blue-900/30 to-blue-800/20" - /> - c > 0).length || 1) - ).toString()} - change={selectedMonth !== 'all' ? - `in ${getMonthName(Number(selectedMonth))}` : - "per active month"} - isPositive={false} - icon={} - bgColor="bg-gradient-to-r from-amber-900/30 to-amber-800/20" - /> - 50} - icon={} - bgColor="bg-gradient-to-r from-green-900/30 to-green-800/20" - /> -
-
- - - - }> -
- {topCategories.length > 0 ? ( - topCategories.map((category) => ( - - )) - ) : ( - - -
- -

No crime data available

-

Try selecting a different time period

-
-
-
- )} -
-
- - )} - - - - }> - - -
-

Crime Severity

-
-
- Low Crime Rate -
-
-
- Medium Crime Rate -
-
-
- High Crime Rate -
-
- - - -
-

Map Markers

-
- - Individual Incident -
-
-
5
- Incident Cluster -
-
-
-
-
- - }> - - -

- SIGAP Crime Map provides real-time visualization and analysis - of crime incidents across Jember region. -

-

- Data is sourced from official police reports and updated - daily to ensure accurate information. -

-
-
- Version - 1.2.4 -
-
- Last Updated - June 18, 2024 -
-
-
-
-
- - }> - - -
-
- -
-
- Filtering -

- Use the year, month, and category filters at the top to - refine the data shown on the map. -

-
-
- -
-
- -
-
- District Information -

- Click on any district to view detailed crime statistics for that area. -

-
-
- -
-
- -
-
- Incidents -

- Click on incident markers to view details about specific crime reports. -

-
-
-
-
-
-
+ + + + + )}
@@ -890,179 +265,3 @@ export default function CrimeSidebar({ ) } - -interface SidebarSectionProps { - title: string - children: React.ReactNode - icon?: React.ReactNode -} - -function SidebarSection({ title, children, icon }: SidebarSectionProps) { - return ( -
-

- {icon} - {title} -

- {children} -
- ) -} - -interface SystemStatusCardProps { - title: string - status: string - statusIcon: React.ReactNode - statusColor: string - updatedTime?: string - bgColor?: string - borderColor?: string -} - -function SystemStatusCard({ - title, - status, - statusIcon, - statusColor, - updatedTime, - bgColor = "bg-sidebar-accent/20", - borderColor = "border-sidebar-border" -}: SystemStatusCardProps) { - return ( - - -
{title}
-
- {statusIcon} - {status} -
- {updatedTime && ( -
{updatedTime}
- )} -
-
- ); -} - -interface EnhancedIncidentCardProps { - title: string - time: string - location: string - severity: "Low" | "Medium" | "High" | "Critical" - onClick?: () => void - showTimeAgo?: boolean -} - -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 ( - - -
- -
-
-

{title}

- {severity} -
-
- - {location} -
-
- {showTimeAgo ? ( - time - ) : ( - <> - - {time} - - )} -
-
-
-
-
- ); -} - -interface StatCardProps { - title: string - value: string - change: string - isPositive?: boolean - icon?: React.ReactNode - bgColor?: string -} - -function StatCard({ - title, - value, - change, - isPositive = false, - icon, - bgColor = "bg-white/10" -}: StatCardProps) { - return ( - - -
- - {icon} - {title} - - {change} -
-
{value}
-
-
- ) -} - -interface CrimeTypeCardProps { - type: string - count: number - percentage: number -} - -function CrimeTypeCard({ type, count, percentage }: CrimeTypeCardProps) { - return ( - - -
- {type} - {count} cases -
-
-
-
-
{percentage}%
-
-
- ) -} diff --git a/sigap-website/app/_components/map/sidebar/tabs/incidents-tab.tsx b/sigap-website/app/_components/map/sidebar/tabs/incidents-tab.tsx new file mode 100644 index 0000000..385fca0 --- /dev/null +++ b/sigap-website/app/_components/map/sidebar/tabs/incidents-tab.tsx @@ -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 + 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 */} + + +
+
+ +
+
+ + {formattedDate} +
+
+ + {formattedTime} +
+
+
+ + {location} +
+
+ + + {crimeStats.totalIncidents || 0} incidents reported + {selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`} + +
+
+
+ + {/* Enhanced stat cards */} +
+ } + statusColor="text-green-400" + updatedTime={getTimePeriodDisplay()} + bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20" + borderColor="border-sidebar-border" + /> + } + 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" + /> + 0 ? topCategories[0].type : "None"} + statusIcon={} + statusColor="text-green-400" + bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20" + borderColor="border-sidebar-border" + /> + } + statusColor="text-purple-400" + updatedTime="Affected areas" + bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20" + borderColor="border-sidebar-border" + /> +
+ + {/* Nested tabs for Recent and History */} + +
+

+ + Incident Reports +

+ + + Recent + + + History + + +
+ + {/* Recent Incidents Tab Content */} + + {crimeStats.recentIncidents.length === 0 ? ( + + +
+ +

+ {selectedCategory !== "all" + ? `No ${selectedCategory} incidents found` + : "No recent incidents reported"} +

+

Try adjusting your filters or checking back later

+
+
+
+ ) : ( +
+ {crimeStats.recentIncidents.slice(0, 6).map((incident: any) => ( + handleIncidentClick(incident)} + /> + ))} +
+ )} +
+ + {/* History Incidents Tab Content */} + + {crimeStats.availableMonths && crimeStats.availableMonths.length === 0 ? ( + + +
+ +

+ {selectedCategory !== "all" + ? `No ${selectedCategory} incidents found in the selected period` + : "No incidents found in the selected period"} +

+

Try adjusting your filters

+
+
+
+ ) : ( +
+
+ + Showing incidents from {crimeStats.availableMonths.length} {crimeStats.availableMonths.length === 1 ? 'month' : 'months'} + + + {selectedCategory !== "all" ? selectedCategory : "All Categories"} + +
+ + {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 ( +
+
+
+ +

{formatMonthKey(monthKey)}

+
+ + {incidents.length} incident{incidents.length !== 1 ? 's' : ''} + +
+ +
+ {paginatedIncidents.map((incident: any) => ( + handleIncidentClick(incident)} + showTimeAgo={false} + /> + ))} +
+ + {totalPages > 1 && ( +
+ + Page {currentPage + 1} of {totalPages} + +
+ + +
+
+ )} +
+ ) + })} +
+ )} +
+
+ + ) +} diff --git a/sigap-website/app/_components/map/sidebar/tabs/info-tab.tsx b/sigap-website/app/_components/map/sidebar/tabs/info-tab.tsx new file mode 100644 index 0000000..e265f3f --- /dev/null +++ b/sigap-website/app/_components/map/sidebar/tabs/info-tab.tsx @@ -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 ( + <> + }> + + +
+

Crime Severity

+
+
+ Low Crime Rate +
+
+
+ Medium Crime Rate +
+
+
+ High Crime Rate +
+
+ + + +
+

Map Markers

+
+ + Individual Incident +
+
+
5
+ Incident Cluster +
+
+
+
+
+ + }> + + +

+ SIGAP Crime Map provides real-time visualization and analysis + of crime incidents across Jember region. +

+

+ Data is sourced from official police reports and updated + daily to ensure accurate information. +

+
+
+ Version + 1.2.4 +
+
+ Last Updated + June 18, 2024 +
+
+
+
+
+ + }> + + +
+
+ +
+
+ Filtering +

+ Use the year, month, and category filters at the top to + refine the data shown on the map. +

+
+
+ +
+
+ +
+
+ District Information +

+ Click on any district to view detailed crime statistics for that area. +

+
+
+ +
+
+ +
+
+ Incidents +

+ Click on incident markers to view details about specific crime reports. +

+
+
+
+
+
+ + ) +} diff --git a/sigap-website/app/_components/map/sidebar/tabs/statistics-tab.tsx b/sigap-website/app/_components/map/sidebar/tabs/statistics-tab.tsx new file mode 100644 index 0000000..b154b5a --- /dev/null +++ b/sigap-website/app/_components/map/sidebar/tabs/statistics-tab.tsx @@ -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 ( + <> + + + + + Monthly Incidents + + {selectedYear} + + +
+ {crimeStats.incidentsByMonth.map((count: number, i: number) => { + const maxCount = Math.max(...crimeStats.incidentsByMonth) + const height = maxCount > 0 ? (count / maxCount) * 100 : 0 + + return ( +
+ ) + })} +
+
+ Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec +
+ + + + }> +
+ } + bgColor="bg-gradient-to-r from-blue-900/30 to-blue-800/20" + /> + c > 0).length || 1) + ).toString()} + change={selectedMonth !== 'all' ? + `in ${getMonthName(Number(selectedMonth))}` : + "per active month"} + isPositive={false} + icon={} + bgColor="bg-gradient-to-r from-amber-900/30 to-amber-800/20" + /> + 50} + icon={} + bgColor="bg-gradient-to-r from-green-900/30 to-green-800/20" + /> +
+
+ + + + }> +
+ {topCategories.length > 0 ? ( + topCategories.map((category: any) => ( + + )) + ) : ( + + +
+ +

No crime data available

+

Try selecting a different time period

+
+
+
+ )} +
+
+ + ) +} diff --git a/sigap-website/app/_hooks/use-pagination.ts b/sigap-website/app/_hooks/use-pagination.ts new file mode 100644 index 0000000..3bd984d --- /dev/null +++ b/sigap-website/app/_hooks/use-pagination.ts @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react'; + +export function usePagination(availableMonths: string[]) { + const [paginationState, setPaginationState] = useState< + Record + >({}); + + useEffect(() => { + if (availableMonths && availableMonths.length > 0) { + const initialState: Record = {}; + 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 }; +} diff --git a/sigap-website/app/_utils/common.ts b/sigap-website/app/_utils/common.ts index 6935bcf..05fae91 100644 --- a/sigap-website/app/_utils/common.ts +++ b/sigap-website/app/_utils/common.ts @@ -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'; +}