821 lines
43 KiB
TypeScript
821 lines
43 KiB
TypeScript
"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
|
|
} from "lucide-react"
|
|
import { Separator } from "@/app/_components/ui/separator"
|
|
import { Card, CardContent, 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 { 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, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
|
|
|
|
interface CrimeSidebarProps {
|
|
className?: string
|
|
defaultCollapsed?: boolean
|
|
selectedCategory?: string | "all"
|
|
selectedYear?: number
|
|
selectedMonth?: number | "all"
|
|
}
|
|
|
|
// Updated interface to match the structure returned by getCrimeByYearAndMonth
|
|
interface ICrimesProps {
|
|
id: string
|
|
district_id: string
|
|
districts: {
|
|
name: string
|
|
geographics?: {
|
|
address: string
|
|
land_area: number
|
|
year: number
|
|
}[]
|
|
demographics?: {
|
|
number_of_unemployed: number
|
|
population: number
|
|
population_density: number
|
|
year: number
|
|
}[]
|
|
}
|
|
number_of_crime: number
|
|
level: $Enums.crime_rates
|
|
score: number
|
|
month: number
|
|
year: number
|
|
crime_incidents: Array<{
|
|
id: string
|
|
timestamp: Date
|
|
description: string
|
|
status: string
|
|
crime_categories: {
|
|
name: string
|
|
type: string
|
|
}
|
|
locations: {
|
|
address: string
|
|
latitude: number
|
|
longitude: number
|
|
}
|
|
}>
|
|
}
|
|
|
|
export default function CrimeSidebar({
|
|
className,
|
|
defaultCollapsed = true,
|
|
selectedCategory = "all",
|
|
selectedYear: propSelectedYear,
|
|
selectedMonth: propSelectedMonth
|
|
}: CrimeSidebarProps) {
|
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
|
const [activeTab, setActiveTab] = useState("incidents")
|
|
const [currentTime, setCurrentTime] = useState<Date>(new Date())
|
|
const [location, setLocation] = useState<string>("Jember, East Java")
|
|
|
|
const {
|
|
availableYears,
|
|
isYearsLoading,
|
|
crimes,
|
|
isCrimesLoading,
|
|
crimesError,
|
|
selectedYear: hookSelectedYear,
|
|
selectedMonth: hookSelectedMonth,
|
|
} = usePrefetchedCrimeData()
|
|
|
|
// Use props for selectedYear and selectedMonth if provided, otherwise fall back to hook values
|
|
const selectedYear = propSelectedYear || hookSelectedYear
|
|
const selectedMonth = propSelectedMonth || hookSelectedMonth
|
|
|
|
// Update current time every minute for the real-time display
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
setCurrentTime(new Date())
|
|
}, 60000)
|
|
|
|
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) // Month is 0-indexed in JS Date
|
|
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
year: 'numeric',
|
|
month: 'long'
|
|
}).format(date)
|
|
}
|
|
|
|
// Otherwise show today's date
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
}).format(currentTime)
|
|
}
|
|
|
|
const formattedDate = getDisplayDate()
|
|
|
|
const formattedTime = new Intl.DateTimeFormat('en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
}).format(currentTime)
|
|
|
|
const { data: categoriesData } = useGetCrimeCategories()
|
|
|
|
const crimeStats = useMemo(() => {
|
|
// Return default values if crimes is undefined, null, or not an array
|
|
if (!crimes || !Array.isArray(crimes) || crimes.length === 0) return {
|
|
todaysIncidents: 0,
|
|
totalIncidents: 0,
|
|
recentIncidents: [],
|
|
categoryCounts: {},
|
|
districts: {},
|
|
incidentsByMonth: Array(12).fill(0),
|
|
clearanceRate: 0
|
|
}
|
|
|
|
// Make sure we have a valid array to work with
|
|
let filteredCrimes = [...crimes]
|
|
|
|
if (selectedCategory !== "all") {
|
|
filteredCrimes = crimes.filter((crime: ICrimesProps) =>
|
|
crime.crime_incidents.some(incident =>
|
|
incident.crime_categories.name === selectedCategory
|
|
)
|
|
)
|
|
}
|
|
|
|
// Collect all incidents from all crimes
|
|
const allIncidents = filteredCrimes.flatMap((crime: ICrimesProps) =>
|
|
crime.crime_incidents.map(incident => ({
|
|
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 = allIncidents.length
|
|
|
|
const today = new Date()
|
|
const thirtyDaysAgo = new Date()
|
|
thirtyDaysAgo.setDate(today.getDate() - 30)
|
|
|
|
const recentIncidents = allIncidents
|
|
.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 todaysIncidents = recentIncidents.filter((incident) => {
|
|
const incidentDate = incident?.timestamp
|
|
? new Date(incident.timestamp)
|
|
: new Date(0)
|
|
return incidentDate.toDateString() === today.toDateString()
|
|
}).length
|
|
|
|
const categoryCounts = allIncidents.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: ICrimesProps) => {
|
|
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)
|
|
allIncidents.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 = allIncidents.filter(incident =>
|
|
incident?.status?.toLowerCase() === "resolved"
|
|
).length
|
|
|
|
const clearanceRate = totalIncidents > 0 ?
|
|
Math.round((resolvedIncidents / totalIncidents) * 100) : 0
|
|
|
|
return {
|
|
todaysIncidents,
|
|
totalIncidents,
|
|
recentIncidents: recentIncidents.slice(0, 10),
|
|
categoryCounts,
|
|
districts,
|
|
incidentsByMonth,
|
|
clearanceRate
|
|
}
|
|
}, [crimes, selectedCategory])
|
|
|
|
// Generate a time period display for the current view
|
|
const getTimePeriodDisplay = () => {
|
|
if (selectedMonth && selectedMonth !== 'all') {
|
|
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
|
|
}
|
|
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"
|
|
}
|
|
|
|
return (
|
|
<div className={cn(
|
|
"fixed top-0 left-0 h-full z-40 transition-transform duration-300 ease-in-out bg-background backdrop-blur-sm border-r border-white/10",
|
|
isCollapsed ? "-translate-x-full" : "translate-x-0",
|
|
className
|
|
)}>
|
|
<div className="relative h-full flex items-stretch">
|
|
<div className="bg-background backdrop-blur-sm border-r border-white/10 h-full w-[400px]">
|
|
<div className="p-4 text-white h-full flex flex-col max-h-full overflow-hidden">
|
|
<CardHeader className="p-0 pb-2 shrink-0">
|
|
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
|
<AlertTriangle className="h-5 w-5" />
|
|
Crime Analysis
|
|
</CardTitle>
|
|
{!isCrimesLoading && (
|
|
<CardDescription className="text-xs text-white/60">
|
|
{getTimePeriodDisplay()}
|
|
</CardDescription>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<Tabs defaultValue="incidents" className="w-full flex-1 flex flex-col overflow-hidden" value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="w-full mb-2 bg-black/30">
|
|
<TabsTrigger value="incidents" className="flex-1">Dashboard</TabsTrigger>
|
|
<TabsTrigger value="statistics" className="flex-1">Statistics</TabsTrigger>
|
|
<TabsTrigger value="info" className="flex-1">Information</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1">
|
|
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
|
|
{isCrimesLoading ? (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-24 w-full" />
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-20 w-full" />
|
|
<Skeleton className="h-20 w-full" />
|
|
<Skeleton className="h-20 w-full" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Card className="bg-black/20 border border-white/10">
|
|
<CardContent className="p-3 text-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="h-4 w-4 text-blue-400" />
|
|
<span className="font-medium">{formattedDate}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4 text-blue-400" />
|
|
<span>{formattedTime}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<MapPin className="h-4 w-4 text-blue-400" />
|
|
<span className="text-white/70">{location}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<AlertTriangle className="h-4 w-4 text-amber-400" />
|
|
<span>
|
|
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
|
|
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<SystemStatusCard
|
|
title="Total Cases"
|
|
status={`${crimeStats?.totalIncidents || 0}`}
|
|
statusIcon={<AlertCircle className="h-4 w-4 text-blue-500" />}
|
|
statusColor="text-blue-500"
|
|
updatedTime={getTimePeriodDisplay()}
|
|
/>
|
|
<SystemStatusCard
|
|
title="Recent Cases"
|
|
status={`${crimeStats?.recentIncidents?.length || 0}`}
|
|
statusIcon={<Clock className="h-4 w-4 text-amber-500" />}
|
|
statusColor="text-amber-500"
|
|
updatedTime="Last 30 days"
|
|
/>
|
|
<SystemStatusCard
|
|
title="Top Category"
|
|
status={topCategories.length > 0 ? topCategories[0].type : "None"}
|
|
statusIcon={<Shield className="h-4 w-4 text-green-500" />}
|
|
statusColor="text-green-500"
|
|
/>
|
|
<SystemStatusCard
|
|
title="Districts"
|
|
status={`${Object.keys(crimeStats.districts).length}`}
|
|
statusIcon={<MapPin className="h-4 w-4 text-purple-500" />}
|
|
statusColor="text-purple-500"
|
|
updatedTime="Affected areas"
|
|
/>
|
|
</div>
|
|
|
|
<SidebarSection
|
|
title={selectedCategory !== "all"
|
|
? `${selectedCategory} Incidents`
|
|
: "Recent Incidents"}
|
|
icon={<AlertTriangle className="h-4 w-4 text-red-400" />}
|
|
>
|
|
{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-2">
|
|
{crimeStats.recentIncidents.slice(0, 6).map((incident) => (
|
|
<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)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SidebarSection>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="statistics" className="m-0 p-0 space-y-4">
|
|
{isCrimesLoading ? (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-64 w-full" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-36 w-full" />
|
|
<Skeleton className="h-48 w-full" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Card className="bg-black/20 border border-white/10 p-3">
|
|
<CardHeader className="p-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Monthly Incidents</CardTitle>
|
|
<CardDescription className="text-xs text-white/60">{selectedYear}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="h-24 flex items-end gap-1 mt-2">
|
|
{crimeStats.incidentsByMonth.map((count, i) => {
|
|
const maxCount = Math.max(...crimeStats.incidentsByMonth)
|
|
const height = maxCount > 0 ? (count / maxCount) * 100 : 0
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"bg-blue-500 w-full rounded-t",
|
|
selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "bg-yellow-500" : ""
|
|
)}
|
|
style={{
|
|
height: `${Math.max(5, height)}%`,
|
|
opacity: 0.6 + (i / 24)
|
|
}}
|
|
title={`${getMonthName(i + 1)}: ${count} incidents`}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="flex justify-between mt-1 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={<BarChart className="h-4 w-4 text-blue-400" />}>
|
|
<div className="space-y-2">
|
|
<StatCard
|
|
title="Total Incidents"
|
|
value={crimeStats.totalIncidents.toString()}
|
|
change={`${Object.keys(crimeStats.districts).length} districts`}
|
|
/>
|
|
<StatCard
|
|
title={selectedMonth !== 'all' ?
|
|
`${getMonthName(Number(selectedMonth))} Cases` :
|
|
"Monthly Average"}
|
|
value={selectedMonth !== 'all' ?
|
|
crimeStats.totalIncidents.toString() :
|
|
Math.round(crimeStats.totalIncidents /
|
|
(crimeStats.incidentsByMonth.filter(c => c > 0).length || 1)
|
|
).toString()}
|
|
change={selectedMonth !== 'all' ?
|
|
`in ${getMonthName(Number(selectedMonth))}` :
|
|
"per active month"}
|
|
isPositive={false}
|
|
/>
|
|
<StatCard
|
|
title="Clearance Rate"
|
|
value={`${crimeStats.clearanceRate}%`}
|
|
change="of cases resolved"
|
|
isPositive={crimeStats.clearanceRate > 50}
|
|
/>
|
|
</div>
|
|
</SidebarSection>
|
|
|
|
<Separator className="bg-white/20 my-2" />
|
|
|
|
<SidebarSection title="Most Common Crimes" icon={<Skull className="h-4 w-4 text-amber-400" />}>
|
|
<div className="space-y-2">
|
|
{topCategories.length > 0 ? (
|
|
topCategories.map((category) => (
|
|
<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>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="info" className="m-0 p-0 space-y-4">
|
|
<SidebarSection title="Map Legend" icon={<MapIcon className="h-4 w-4 text-blue-400" />}>
|
|
<Card className="bg-black/20 border border-white/10">
|
|
<CardContent className="p-3 text-xs space-y-2">
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium mb-1">Crime Severity</h4>
|
|
<div className="flex items-center gap-2">
|
|
<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">
|
|
<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">
|
|
<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-2" />
|
|
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium mb-1">Map Markers</h4>
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
|
<span>Individual Incident</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center text-[8px] text-white">5</div>
|
|
<span>Incident Cluster</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</SidebarSection>
|
|
|
|
<SidebarSection title="About" icon={<Info className="h-4 w-4 text-blue-400" />}>
|
|
<Card className="bg-black/20 border border-white/10">
|
|
<CardContent className="p-3 text-xs">
|
|
<p className="mb-2">
|
|
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-2 text-white/60">
|
|
<div className="flex justify-between">
|
|
<span>Version</span>
|
|
<span>1.2.4</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Last Updated</span>
|
|
<span>June 18, 2024</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</SidebarSection>
|
|
|
|
<SidebarSection title="How to Use" icon={<Eye className="h-4 w-4 text-blue-400" />}>
|
|
<Card className="bg-black/20 border border-white/10">
|
|
<CardContent className="p-3 text-xs space-y-2">
|
|
<div>
|
|
<span className="font-medium">Filtering</span>
|
|
<p className="text-white/70">
|
|
Use the year, month, and category filters at the top to
|
|
refine the data shown on the map.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="font-medium">District Information</span>
|
|
<p className="text-white/70">
|
|
Click on any district to view detailed crime statistics for that area.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="font-medium">Incidents</span>
|
|
<p className="text-white/70">
|
|
Click on incident markers to view details about specific crime reports.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</SidebarSection>
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
className={cn(
|
|
"absolute h-12 w-8 bg-background backdrop-blur-sm border-t border-b border-r border-white/10 flex items-center justify-center",
|
|
"top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out",
|
|
isCollapsed ? "-right-8 rounded-r-md" : "left-[400px] rounded-r-md",
|
|
)}
|
|
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
>
|
|
<ChevronRight
|
|
className={cn("h-5 w-5 text-white/80 transition-transform",
|
|
!isCollapsed && "rotate-180")}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface SidebarSectionProps {
|
|
title: string
|
|
children: React.ReactNode
|
|
icon?: React.ReactNode
|
|
}
|
|
|
|
function SidebarSection({ title, children, icon }: SidebarSectionProps) {
|
|
return (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-white/80 mb-2 flex items-center gap-1.5">
|
|
{icon}
|
|
{title}
|
|
</h3>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface SystemStatusCardProps {
|
|
title: string
|
|
status: string
|
|
statusIcon: React.ReactNode
|
|
statusColor: string
|
|
updatedTime?: string
|
|
}
|
|
|
|
function SystemStatusCard({ title, status, statusIcon, statusColor, updatedTime }: SystemStatusCardProps) {
|
|
return (
|
|
<Card className="bg-black/20 border border-white/10">
|
|
<CardContent className="p-2 text-xs">
|
|
<div className="font-medium mb-1">{title}</div>
|
|
<div className={`flex items-center gap-1 ${statusColor}`}>
|
|
{statusIcon}
|
|
<span>{status}</span>
|
|
</div>
|
|
{updatedTime && (
|
|
<div className="text-white/50 text-[10px] mt-1">{updatedTime}</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
interface EnhancedIncidentCardProps {
|
|
title: string
|
|
time: string
|
|
location: string
|
|
severity: "Low" | "Medium" | "High" | "Critical"
|
|
}
|
|
|
|
function IncidentCard({ title, time, location, severity }: 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";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className="bg-white/10 border-0 text-white shadow-none">
|
|
<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 text-white/60">
|
|
<MapPin className="h-3 w-3" />
|
|
<span>{location}</span>
|
|
</div>
|
|
<div className="mt-1 text-white/60">{time}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
interface StatCardProps {
|
|
title: string
|
|
value: string
|
|
change: string
|
|
isPositive?: boolean
|
|
}
|
|
|
|
function StatCard({ title, value, change, isPositive = false }: StatCardProps) {
|
|
return (
|
|
<Card className="bg-white/10 border-0 text-white shadow-none">
|
|
<CardContent className="p-3">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-xs text-white/70">{title}</span>
|
|
<span className={`text-xs ${isPositive ? "text-green-400" : "text-red-400"}`}>{change}</span>
|
|
</div>
|
|
<div className="text-xl font-bold mt-1">{value}</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
interface CrimeTypeCardProps {
|
|
type: string
|
|
count: number
|
|
percentage: number
|
|
}
|
|
|
|
function CrimeTypeCard({ type, count, percentage }: CrimeTypeCardProps) {
|
|
return (
|
|
<Card className="bg-white/10 border-0 text-white shadow-none">
|
|
<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-1.5 bg-white/20 rounded-full overflow-hidden">
|
|
<div className="bg-blue-500 h-full rounded-full" style={{ width: `${percentage}%` }}></div>
|
|
</div>
|
|
<div className="mt-1 text-xs text-white/70 text-right">{percentage}%</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
interface ReportCardProps {
|
|
title: string
|
|
date: string
|
|
author: string
|
|
}
|
|
|
|
function ReportCard({ title, date, author }: ReportCardProps) {
|
|
return (
|
|
<Card className="bg-white/10 border-0 text-white shadow-none">
|
|
<CardContent className="p-3 text-xs">
|
|
<div className="flex items-start gap-2">
|
|
<FileText className="h-4 w-4 text-indigo-400 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium">{title}</p>
|
|
<div className="flex items-center gap-2 mt-1 text-white/60">
|
|
<Shield className="h-3 w-3" />
|
|
<span>{author}</span>
|
|
</div>
|
|
<div className="mt-1 text-white/60">{date}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function PieChart(props: any) {
|
|
return <BarChart {...props} />;
|
|
}
|