diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index aa52b2c..945874f 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -298,6 +298,7 @@ export default function CrimeMap() { {/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */} + crimes: ICrimes[] + isLoading?: boolean } export default function CrimeSidebar({ className, defaultCollapsed = true, selectedCategory = "all", - selectedYear: propSelectedYear, - selectedMonth: propSelectedMonth + selectedYear, + selectedMonth, + crimes = [], + isLoading = false, }: CrimeSidebarProps) { const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed) const [activeTab, setActiveTab] = useState("incidents") + const [activeIncidentTab, setActiveIncidentTab] = useState("recent") const [currentTime, setCurrentTime] = useState(new Date()) const [location, setLocation] = useState("Jember, East Java") + const [paginationState, setPaginationState] = useState>({}) - const { - availableYears, - isYearsLoading, - crimes, - isCrimesLoading, - crimesError, - selectedYear: hookSelectedYear, - selectedMonth: hookSelectedMonth, - } = usePrefetchedCrimeData() + const { current: map } = useMap() - // 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()) @@ -98,13 +53,11 @@ 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) // Month is 0-indexed in JS Date + date.setMonth(Number(selectedMonth) - 1) return new Intl.DateTimeFormat('en-US', { year: 'numeric', @@ -112,7 +65,6 @@ export default function CrimeSidebar({ }).format(date) } - // Otherwise show today's date return new Intl.DateTimeFormat('en-US', { weekday: 'long', year: 'numeric', @@ -129,33 +81,23 @@ export default function CrimeSidebar({ 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: [], + filteredIncidents: [], categoryCounts: {}, districts: {}, incidentsByMonth: Array(12).fill(0), - clearanceRate: 0 + clearanceRate: 0, + incidentsByMonthDetail: {} as Record, + availableMonths: [] as string[] } - // 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) => + const crimeIncidents = filteredCrimes.flatMap((crime: ICrimes) => crime.crime_incidents.map(incident => ({ id: incident.id, timestamp: incident.timestamp, @@ -169,13 +111,13 @@ export default function CrimeSidebar({ })) ) - const totalIncidents = allIncidents.length + const totalIncidents = crimeIncidents.length const today = new Date() const thirtyDaysAgo = new Date() thirtyDaysAgo.setDate(today.getDate() - 30) - const recentIncidents = allIncidents + const recentIncidents = crimeIncidents .filter((incident) => { if (!incident?.timestamp) return false const incidentDate = new Date(incident.timestamp) @@ -187,6 +129,12 @@ export default function CrimeSidebar({ 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) @@ -194,20 +142,20 @@ export default function CrimeSidebar({ return incidentDate.toDateString() === today.toDateString() }).length - const categoryCounts = allIncidents.reduce((acc: Record, incident) => { + 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: ICrimesProps) => { + 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) - allIncidents.forEach((incident) => { + crimeIncidents.forEach((incident) => { if (!incident?.timestamp) return; const date = new Date(incident.timestamp) @@ -217,25 +165,89 @@ export default function CrimeSidebar({ } }) - const resolvedIncidents = allIncidents.filter(incident => + const resolvedIncidents = crimeIncidents.filter(incident => incident?.status?.toLowerCase() === "resolved" ).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 + clearanceRate, + incidentsByMonthDetail, + availableMonths } - }, [crimes, selectedCategory]) + }, [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}` @@ -292,6 +304,30 @@ export default function CrimeSidebar({ return "Low" } + const handleIncidentClick = (incident: any) => { + if (!map || !incident.longitude || !incident.latitude) return + + map.flyTo({ + center: [incident.longitude, incident.latitude], + zoom: 15, + pitch: 60, + bearing: 0, + duration: 1500, + easing: (t) => t * (2 - t), + }) + + const customEvent = new CustomEvent("incident_click", { + detail: incident, + bubbles: true + }) + + if (map.getMap().getCanvas()) { + map.getMap().getCanvas().dispatchEvent(customEvent) + } else { + document.dispatchEvent(customEvent) + } + } + return (
- {/* Header with improved styling */}
- {/* Improved tabs with pill style */} - {isCrimesLoading ? ( + {isLoading ? (
@@ -377,8 +411,7 @@ export default function CrimeSidebar({
) : ( - <> - {/* Enhanced info card */} + <>
@@ -408,7 +441,6 @@ export default function CrimeSidebar({
- {/* Enhanced stat cards */}
- } - > - {crimeStats.recentIncidents.length === 0 ? ( - - -
- -

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

-

Try adjusting your filters or checking back later

-
-
-
- ) : ( + +
+

+ + Incident Reports +

+ + + Recent + + + History + + +
+ + + {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) => ( handleIncidentClick(incident)} /> ))}
- )} -
+ )} +
+ + + {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 => { + 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) => ( + handleIncidentClick(incident)} + showTimeAgo={false} + /> + ))} +
+ + {totalPages > 1 && ( +
+ + Page {currentPage + 1} of {totalPages} + +
+ + +
+
+ )} +
+ ) + })} +
+ )} +
+
)} - - {isCrimesLoading ? ( + {isLoading ? (
@@ -795,9 +949,11 @@ interface EnhancedIncidentCardProps { time: string location: string severity: "Low" | "Medium" | "High" | "Critical" + onClick?: () => void + showTimeAgo?: boolean } -function IncidentCard({ title, time, location, severity }: EnhancedIncidentCardProps) { +function IncidentCard({ title, time, location, severity, onClick, showTimeAgo = true }: EnhancedIncidentCardProps) { const getBadgeColor = () => { switch (severity) { case "Low": return "bg-green-500/20 text-green-300"; @@ -819,7 +975,10 @@ function IncidentCard({ title, time, location, severity }: EnhancedIncidentCardP }; return ( - +
@@ -832,7 +991,16 @@ function IncidentCard({ title, time, location, severity }: EnhancedIncidentCardP {location}
-
{time}
+
+ {showTimeAgo ? ( + time + ) : ( + <> + + {time} + + )} +