MIF_E31221222/sigap-website/app/_components/map/controls/top/search-control.tsx

548 lines
28 KiB
TypeScript

"use client"
import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { Search, XCircle, Info, ExternalLink, Calendar, MapPin, MessageSquare, FileText, Map, FolderOpen } from 'lucide-react'
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import ActionSearchBar from "@/app/_components/action-search-bar"
import { Card } from "@/app/_components/ui/card"
import { format } from 'date-fns'
import { ITooltips } from "./tooltips"
// Expanded sample crime data with more entries for testing
const SAMPLE_CRIME_DATA = [
{ id: "CR-12345-2023", description: "Robbery at Main Street" },
{ id: "CR-23456-2023", description: "Assault in Central Park" },
{ id: "CI-7890-2023", description: "Burglary report at Downtown" },
{ id: "CI-4567-2024", description: "Vandalism at City Hall" },
{ id: "CI-4167-2024", description: "Graffiti at Public Library" },
{ id: "CI-4067-2024", description: "Property damage at School" },
{ id: "CR-34567-2024", description: "Car theft on 5th Avenue" },
{ id: "CR-34517-2024", description: "Mugging at Central Station" },
{ id: "CR-14517-2024", description: "Shoplifting at Mall" },
{ id: "CR-24517-2024", description: "Break-in at Office Building" },
];
// Generate additional sample data for testing scrolling
const generateSampleData = () => {
const additionalData = [];
for (let i = 1; i <= 90; i++) {
// Mix of crime and incident IDs
const prefix = i % 2 === 0 ? "CR-" : "CI-";
const id = `${prefix}${10000 + i}-${2022 + i % 3}`;
const descriptions = [
"Theft at residence",
"Traffic violation",
"Noise complaint",
"Suspicious activity",
"Drug related incident",
"Vandalism of public property",
"Illegal parking",
"Public disturbance",
"Domestic dispute",
"Assault case"
];
const description = descriptions[i % descriptions.length];
// Add more detailed properties for enhanced suggestions
additionalData.push({
id,
description: `${description} #${i}`,
coordinates: `${-6 - (i % 5) * 0.01}, ${106 + (i % 7) * 0.01}`,
address: `Jl. ${["Sudirman", "Thamrin", "Gatot Subroto", "Rasuna Said", "Asia Afrika"][i % 5]} No. ${i + 10}, Jakarta`,
date: new Date(2022 + (i % 3), i % 12, i % 28 + 1),
type: ["Theft", "Assault", "Vandalism", "Robbery", "Fraud"][i % 5],
category: ["Property Crime", "Violent Crime", "Public Disturbance", "White Collar", "Misdemeanor"][i % 5]
});
}
return [...SAMPLE_CRIME_DATA, ...additionalData];
}
const EXPANDED_SAMPLE_DATA = generateSampleData();
const ACTIONS = [
{
id: "incident_id",
label: "Search by Incident ID",
icon: <FileText className="h-4 w-4 text-orange-500" />,
description: "e.g., CI-789",
category: "Search",
prefix: "CI-",
regex: /^CI-\d+(-\d{4})?$/,
placeholder: "CI-7890-2023",
},
{
id: "coordinates",
label: "Search by Coordinates",
icon: <Map className="h-4 w-4 text-green-500" />,
description: "e.g., -6.2, 106.8",
category: "Search",
prefix: "",
regex: /^-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?$/,
placeholder: "-6.2, 106.8",
},
{
id: "description",
label: "Search by Description",
icon: <MessageSquare className="h-4 w-4 text-purple-500" />,
description: "e.g., robbery",
category: "Search",
prefix: "",
regex: /.+/,
placeholder: "Enter crime description",
},
{
id: "address",
label: "Search by Address",
icon: <FolderOpen className="h-4 w-4 text-amber-500" />,
description: "e.g., Jalan Sudirman",
category: "Search",
prefix: "",
regex: /.+/,
placeholder: "Enter location or address",
},
]
interface SearchTooltipProps {
onControlChange?: (controlId: ITooltips) => void
activeControl?: string
}
export default function SearchTooltip({ onControlChange, activeControl }: SearchTooltipProps) {
const [showSearch, setShowSearch] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const [selectedSearchType, setSelectedSearchType] = useState<string | null>(null)
const [searchValue, setSearchValue] = useState("")
const [suggestions, setSuggestions] = useState<Array<{ id: string, description: string }>>([])
const [isInputValid, setIsInputValid] = useState(true)
const [selectedSuggestion, setSelectedSuggestion] = useState<{
id: string;
description: string;
latitude?: number;
longitude?: number;
timestamp?: Date;
category?: string;
type?: string;
address?: string;
} | null>(null)
const [showInfoBox, setShowInfoBox] = useState(false)
useEffect(() => {
if (showSearch && searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
}
}, [showSearch]);
const handleSearchTypeSelect = (actionId: string) => {
const selectedAction = ACTIONS.find(action => action.id === actionId);
if (selectedAction) {
setSelectedSearchType(actionId);
const prefix = selectedAction.prefix || "";
setSearchValue(prefix);
setIsInputValid(true);
// Immediately filter and show suggestions based on the selected search type
let initialSuggestions: Array<{ id: string, description: string }> = [];
if (actionId === 'crime_id') {
initialSuggestions = EXPANDED_SAMPLE_DATA.filter(item => item.id.startsWith('CR-'));
} else if (actionId === 'incident_id') {
initialSuggestions = EXPANDED_SAMPLE_DATA.filter(item => item.id.startsWith('CI-'));
} else if (actionId === 'description' || actionId === 'address') {
initialSuggestions = EXPANDED_SAMPLE_DATA;
}
// Force a re-render by setting suggestions in the next tick
setTimeout(() => {
setSuggestions(initialSuggestions);
}, 0);
setTimeout(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
searchInputRef.current.selectionStart = prefix.length;
searchInputRef.current.selectionEnd = prefix.length;
}
}, 50);
}
}
// Create a helper function for filtering suggestions
const filterSuggestions = (searchType: string, searchText: string): Array<{ id: string, description: string }> => {
let filtered: Array<{ id: string, description: string }> = [];
if (searchType === 'crime_id') {
if (!searchText || searchText === 'CR-') {
filtered = EXPANDED_SAMPLE_DATA.filter(item => item.id.startsWith('CR-'));
} else {
filtered = EXPANDED_SAMPLE_DATA.filter(item =>
item.id.startsWith('CR-') &&
(item.id.toLowerCase().includes(searchText.toLowerCase()) ||
item.description.toLowerCase().includes(searchText.toLowerCase()))
);
}
}
else if (searchType === 'incident_id') {
if (!searchText || searchText === 'CI-') {
filtered = EXPANDED_SAMPLE_DATA.filter(item => item.id.startsWith('CI-'));
} else {
filtered = EXPANDED_SAMPLE_DATA.filter(item =>
item.id.startsWith('CI-') &&
(item.id.toLowerCase().includes(searchText.toLowerCase()) ||
item.description.toLowerCase().includes(searchText.toLowerCase()))
);
}
}
else if (searchType === 'description') {
if (!searchText) {
filtered = EXPANDED_SAMPLE_DATA;
} else {
filtered = EXPANDED_SAMPLE_DATA.filter(item =>
item.description.toLowerCase().includes(searchText.toLowerCase())
);
}
}
else if (searchType === 'address') {
if (!searchText) {
filtered = EXPANDED_SAMPLE_DATA;
} else {
filtered = EXPANDED_SAMPLE_DATA.filter(item =>
item.description.toLowerCase().includes(searchText.toLowerCase())
);
}
}
return filtered;
};
const handleSearchChange = (value: string) => {
const currentSearchType = selectedSearchType ?
ACTIONS.find(action => action.id === selectedSearchType) : null;
if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) {
if (!value.startsWith(currentSearchType.prefix)) {
value = currentSearchType.prefix;
}
}
setSearchValue(value);
if (currentSearchType?.regex) {
if (!value || value === currentSearchType.prefix) {
setIsInputValid(true);
} else {
setIsInputValid(currentSearchType.regex.test(value));
}
} else {
setIsInputValid(true);
}
if (!selectedSearchType) {
setSuggestions([]);
return;
}
// Use the helper function to filter suggestions
setSuggestions(filterSuggestions(selectedSearchType, value));
}
const handleClearSearchType = () => {
setSelectedSearchType(null);
setSearchValue("");
setSuggestions([]);
if (searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
}
};
const handleSuggestionSelect = (item: { id: string, description: string }) => {
setSearchValue(item.id);
setSuggestions([]);
const fullIncidentData = {
...item,
timestamp: new Date(),
latitude: -6.2088,
longitude: 106.8456,
category: selectedSearchType === 'crime_id' ? "Theft" : "Vandalism",
type: selectedSearchType === 'crime_id' ? "Property Crime" : "Public Disturbance",
address: "Jl. Sudirman No. 123, Jakarta"
};
setSelectedSuggestion(fullIncidentData);
setShowInfoBox(true);
};
const handleFlyToIncident = () => {
if (!selectedSuggestion || !selectedSuggestion.latitude || !selectedSuggestion.longitude) return;
const flyToEvent = new CustomEvent('fly_to_incident', {
detail: {
longitude: selectedSuggestion.longitude,
latitude: selectedSuggestion.latitude,
id: selectedSuggestion.id,
zoom: 15
},
bubbles: true
});
document.dispatchEvent(flyToEvent);
setShowInfoBox(false);
setSelectedSuggestion(null);
toggleSearch();
};
const handleCloseInfoBox = () => {
setShowInfoBox(false);
setSelectedSuggestion(null);
// Restore original suggestions for the current search type
if (selectedSearchType) {
const initialSuggestions = filterSuggestions(selectedSearchType, searchValue);
setSuggestions(initialSuggestions);
}
};
const toggleSearch = () => {
setShowSearch(!showSearch)
if (!showSearch && onControlChange) {
onControlChange("search" as ITooltips)
setSelectedSearchType(null);
setSearchValue("");
setSuggestions([]);
}
}
return (
<>
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showSearch ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${showSearch
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background"
}`}
onClick={toggleSearch}
>
<Search size={20} />
<span className="sr-only">Search Incidents</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Search Incidents</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<AnimatePresence>
{showSearch && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
onClick={toggleSearch}
/>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed transform top-1/4 left-1/4 z-50 w-full max-w-lg sm:max-w-xl md:max-w-3xl"
>
<div className="bg-background border border-border rounded-lg shadow-xl p-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-medium">Search Incidents</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-emerald-500/90 hover:text-background"
onClick={toggleSearch}
>
<XCircle size={18} />
</Button>
</div>
{!showInfoBox ? (
<>
<ActionSearchBar
ref={searchInputRef}
autoFocus
isFloating
defaultActions={false}
actions={ACTIONS}
onActionSelect={handleSearchTypeSelect}
onClearAction={handleClearSearchType}
activeActionId={selectedSearchType}
value={searchValue}
onChange={handleSearchChange}
placeholder={selectedSearchType ?
ACTIONS.find(a => a.id === selectedSearchType)?.placeholder :
"Select a search type..."}
inputClassName={!isInputValid ?
"border-destructive focus-visible:ring-destructive bg-destructive/50" : ""}
/>
{!isInputValid && selectedSearchType && (
<div className="mt-1 text-xs text-destructive">
Invalid format. {ACTIONS.find(a => a.id === selectedSearchType)?.description}
</div>
)}
{(suggestions.length > 0 && selectedSearchType) && (
<div className="mt-2 max-h-[300px] overflow-y-auto border border-border rounded-md bg-background/80 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
<div className="sticky top-0 bg-muted/70 backdrop-blur-sm px-3 py-2 border-b border-border">
<p className="text-xs text-muted-foreground">
{suggestions.length} results found
</p>
</div>
<ul className="py-1">
{suggestions.map((item, index) => (
<li
key={index}
className="px-3 py-2 hover:bg-muted cursor-pointer flex items-center justify-between"
onClick={() => handleSuggestionSelect(item)}
>
<span className="font-medium">{item.id}</span>
<div className="flex items-center gap-2">
{/* Show different information based on search type */}
{selectedSearchType === 'crime_id' || selectedSearchType === 'incident_id' ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{item.description}
</span>
) : selectedSearchType === 'coordinates' && 'coordinates' in item ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{typeof item.coordinates === 'string' ? item.coordinates : 'N/A'} - {item.description}
</span>
) : selectedSearchType === 'address' && 'address' in item ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{'address' in item && typeof item.address === 'string' ? item.address : 'N/A'}
</span>
) : (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{item.description}
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 rounded-full hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
handleSuggestionSelect(item);
}}
>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</li>
))}
</ul>
</div>
)}
{selectedSearchType && searchValue.length > (ACTIONS.find(a => a.id === selectedSearchType)?.prefix?.length || 0) &&
suggestions.length === 0 && (
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
<p className="text-sm text-muted-foreground">No matching incidents found</p>
</div>
)}
<div className="mt-3 px-3 py-2 border-t border-border bg-muted/30 rounded-b-md">
<p className="flex items-center text-sm text-muted-foreground">
{selectedSearchType ? (
<>
<span className="mr-1">{ACTIONS.find(a => a.id === selectedSearchType)?.icon}</span>
<span>
{ACTIONS.find(a => a.id === selectedSearchType)?.description}
</span>
</>
) : (
<span>
Select a search type and enter your search criteria
</span>
)}
</p>
</div>
</>
) : (
<Card className="p-4 border border-border">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-semibold">{selectedSuggestion?.id}</h3>
</div>
{selectedSuggestion && (
<div className="space-y-3">
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Info className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.description}</p>
</div>
{selectedSuggestion.timestamp && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">
{format(selectedSuggestion.timestamp, 'PPP p')}
</p>
</div>
)}
{selectedSuggestion.address && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<MapPin className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.address}</p>
</div>
)}
<div className="grid grid-cols-2 gap-2 mt-2">
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Category</p>
<p className="text-sm">{selectedSuggestion.category || 'N/A'}</p>
</div>
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Type</p>
<p className="text-sm">{selectedSuggestion.type || 'N/A'}</p>
</div>
</div>
<div className="flex justify-between items-center pt-3 border-t border-border mt-3">
<Button
variant="outline"
size="sm"
onClick={handleCloseInfoBox}
>
Close
</Button>
<Button
variant="default"
size="sm"
onClick={handleFlyToIncident}
disabled={!selectedSuggestion.latitude || !selectedSuggestion.longitude}
className="flex items-center gap-2"
>
<span>Fly to Incident</span>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</Card>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
)
}