Implement Tourist Guide System
This commit is contained in:
20
src/App.tsx
20
src/App.tsx
@@ -49,6 +49,9 @@ import Integrations from "./pages/dashboard/config/Integrations";
|
|||||||
import Audit from "./pages/dashboard/config/Audit";
|
import Audit from "./pages/dashboard/config/Audit";
|
||||||
import SecurityCenter from "./pages/dashboard/config/SecurityCenter";
|
import SecurityCenter from "./pages/dashboard/config/SecurityCenter";
|
||||||
import PersonalizationPage from "./pages/dashboard/config/PersonalizationPage";
|
import PersonalizationPage from "./pages/dashboard/config/PersonalizationPage";
|
||||||
|
// POLITUR pages
|
||||||
|
import EmergencyDashboard from "./pages/dashboard/politur/EmergencyDashboard";
|
||||||
|
import PolReports from "./pages/dashboard/politur/Reports";
|
||||||
// Commerce pages (for retail stores)
|
// Commerce pages (for retail stores)
|
||||||
import CommerceStore from "./pages/dashboard/commerce/Store";
|
import CommerceStore from "./pages/dashboard/commerce/Store";
|
||||||
import CommercePOS from "./pages/dashboard/commerce/POSTerminal";
|
import CommercePOS from "./pages/dashboard/commerce/POSTerminal";
|
||||||
@@ -570,6 +573,23 @@ const AppRouter = () => (
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* POLITUR Routes */}
|
||||||
|
<Route path="/dashboard/politur/emergency" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardLayout>
|
||||||
|
<EmergencyDashboard />
|
||||||
|
</DashboardLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/dashboard/politur/reports" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardLayout>
|
||||||
|
<PolReports />
|
||||||
|
</DashboardLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Catch-all route */}
|
{/* Catch-all route */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Leaf,
|
Leaf,
|
||||||
Store,
|
Store,
|
||||||
Server
|
Server,
|
||||||
|
ShieldAlert
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -176,6 +177,15 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
},
|
},
|
||||||
{ icon: Car, label: 'Vehicle Management', path: '/dashboard/vehicle-management' },
|
{ icon: Car, label: 'Vehicle Management', path: '/dashboard/vehicle-management' },
|
||||||
{ icon: Leaf, label: 'Sustainability', path: '/dashboard/sustainability' },
|
{ icon: Leaf, label: 'Sustainability', path: '/dashboard/sustainability' },
|
||||||
|
{
|
||||||
|
icon: ShieldAlert,
|
||||||
|
label: 'POLITUR',
|
||||||
|
path: '/dashboard/politur',
|
||||||
|
subItems: [
|
||||||
|
{ icon: AlertTriangle, label: 'Emergencias', path: '/dashboard/politur/emergency' },
|
||||||
|
{ icon: FileText, label: 'Reportes', path: '/dashboard/politur/reports' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Store,
|
icon: Store,
|
||||||
label: t('commerce'),
|
label: t('commerce'),
|
||||||
|
|||||||
124
src/components/politur/EmergencyMap.tsx
Normal file
124
src/components/politur/EmergencyMap.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Emergency } from '@/types/politur';
|
||||||
|
|
||||||
|
interface EmergencyMapProps {
|
||||||
|
emergencies: Emergency[];
|
||||||
|
onSelectEmergency: (emergency: Emergency) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmergencyMap: React.FC<EmergencyMapProps> = ({ emergencies, onSelectEmergency }) => {
|
||||||
|
const mapContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainer.current) return;
|
||||||
|
|
||||||
|
// Simulated map - in production, integrate with Mapbox or Google Maps
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = mapContainer.current.clientWidth;
|
||||||
|
canvas.height = 400;
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = '400px';
|
||||||
|
canvas.style.borderRadius = '8px';
|
||||||
|
canvas.style.backgroundColor = '#f0f0f0';
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Draw simple map representation
|
||||||
|
ctx.fillStyle = '#e0e0e0';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
ctx.strokeStyle = '#d0d0d0';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i < canvas.width; i += 50) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(i, 0);
|
||||||
|
ctx.lineTo(i, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let i = 0; i < canvas.height; i += 50) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, i);
|
||||||
|
ctx.lineTo(canvas.width, i);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw title
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.font = '16px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Mapa de Santo Domingo', canvas.width / 2, 30);
|
||||||
|
ctx.font = '12px Arial';
|
||||||
|
ctx.fillText('(Integración con Mapbox próximamente)', canvas.width / 2, 50);
|
||||||
|
|
||||||
|
// Draw emergency markers
|
||||||
|
emergencies.forEach((emergency, index) => {
|
||||||
|
const x = 100 + (index * 150) % (canvas.width - 200);
|
||||||
|
const y = 100 + Math.floor(index / 4) * 100;
|
||||||
|
|
||||||
|
// Marker circle
|
||||||
|
const color = emergency.priority === 'critical' ? '#ef4444' :
|
||||||
|
emergency.priority === 'high' ? '#f97316' :
|
||||||
|
emergency.priority === 'medium' ? '#eab308' : '#22c55e';
|
||||||
|
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 15, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Marker border
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pulse effect for critical
|
||||||
|
if (emergency.priority === 'critical') {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.globalAlpha = 0.3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 25, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.font = '11px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(emergency.touristName.split(' ')[0], x, y + 35);
|
||||||
|
});
|
||||||
|
|
||||||
|
mapContainer.current.innerHTML = '';
|
||||||
|
mapContainer.current.appendChild(canvas);
|
||||||
|
|
||||||
|
// Add click handler
|
||||||
|
canvas.addEventListener('click', (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||||
|
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||||||
|
|
||||||
|
emergencies.forEach((emergency, index) => {
|
||||||
|
const markerX = 100 + (index * 150) % (canvas.width - 200);
|
||||||
|
const markerY = 100 + Math.floor(index / 4) * 100;
|
||||||
|
const distance = Math.sqrt(Math.pow(x - markerX, 2) + Math.pow(y - markerY, 2));
|
||||||
|
|
||||||
|
if (distance < 20) {
|
||||||
|
onSelectEmergency(emergency);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
}, [emergencies, onSelectEmergency]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={mapContainer} className="w-full h-[400px] rounded-lg overflow-hidden">
|
||||||
|
{/* Map will be rendered here */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmergencyMap;
|
||||||
140
src/components/politur/IncidentDetails.tsx
Normal file
140
src/components/politur/IncidentDetails.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { X, Phone, Video, Navigation, MapPin, Clock, User, FileText } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Emergency } from '@/types/politur';
|
||||||
|
|
||||||
|
interface IncidentDetailsProps {
|
||||||
|
emergency: Emergency;
|
||||||
|
onClose: () => void;
|
||||||
|
onAssign: () => void;
|
||||||
|
onResolve: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IncidentDetails: React.FC<IncidentDetailsProps> = ({
|
||||||
|
emergency,
|
||||||
|
onClose,
|
||||||
|
onAssign,
|
||||||
|
onResolve
|
||||||
|
}) => {
|
||||||
|
const openNavigation = () => {
|
||||||
|
const url = `https://www.google.com/maps/dir/?api=1&destination=${emergency.location.lat},${emergency.location.lng}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between">
|
||||||
|
<span>Detalles de la Emergencia</span>
|
||||||
|
<Badge variant={emergency.priority === 'critical' ? 'destructive' : 'default'}>
|
||||||
|
{emergency.priority.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tourist Info */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<User className="w-5 h-5 text-gray-600" />
|
||||||
|
<h3 className="font-semibold">Información del Turista</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p><strong>Nombre:</strong> {emergency.touristName}</p>
|
||||||
|
<p><strong>Teléfono:</strong> {emergency.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Info */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<MapPin className="w-5 h-5 text-gray-600" />
|
||||||
|
<h3 className="font-semibold">Ubicación</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mb-2">{emergency.address}</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={openNavigation}
|
||||||
|
>
|
||||||
|
<Navigation className="w-4 h-4 mr-2" />
|
||||||
|
Navegar hacia la ubicación
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incident Info */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FileText className="w-5 h-5 text-gray-600" />
|
||||||
|
<h3 className="font-semibold">Descripción del Incidente</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mb-2">{emergency.description}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Reportado: {new Date(emergency.timestamp).toLocaleString('es-ES')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{emergency.assignedOfficer && (
|
||||||
|
<div className="bg-blue-50 p-4 rounded-lg">
|
||||||
|
<p className="text-sm">
|
||||||
|
<strong>Oficial asignado:</strong> {emergency.assignedOfficer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => window.location.href = `tel:${emergency.phone}`}
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4 mr-2" />
|
||||||
|
Llamar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {/* Video call implementation */}}
|
||||||
|
>
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
Videollamada
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emergency.status === 'pending' && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
onAssign();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Asignarme esta emergencia
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emergency.status === 'assigned' && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
onResolve();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Marcar como Resuelta
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IncidentDetails;
|
||||||
271
src/pages/dashboard/politur/EmergencyDashboard.tsx
Normal file
271
src/pages/dashboard/politur/EmergencyDashboard.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { AlertTriangle, MapPin, Phone, Video, Clock, CheckCircle, XCircle, Navigation } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import EmergencyMap from '@/components/politur/EmergencyMap';
|
||||||
|
import IncidentDetails from '@/components/politur/IncidentDetails';
|
||||||
|
import { Emergency } from '@/types/politur';
|
||||||
|
|
||||||
|
|
||||||
|
const EmergencyDashboard = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [emergencies, setEmergencies] = useState<Emergency[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
touristName: 'John Smith',
|
||||||
|
location: { lat: 18.4861, lng: -69.9312 },
|
||||||
|
address: 'Zona Colonial, Santo Domingo',
|
||||||
|
type: 'security',
|
||||||
|
priority: 'high',
|
||||||
|
status: 'pending',
|
||||||
|
description: 'Robo reportado en la zona',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
phone: '+1-555-0123'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
touristName: 'Maria Garcia',
|
||||||
|
location: { lat: 18.4721, lng: -69.8933 },
|
||||||
|
address: 'Malecón, Santo Domingo',
|
||||||
|
type: 'medical',
|
||||||
|
priority: 'critical',
|
||||||
|
status: 'assigned',
|
||||||
|
description: 'Emergencia médica - dolor de pecho',
|
||||||
|
timestamp: new Date(Date.now() - 300000).toISOString(),
|
||||||
|
assignedOfficer: 'Oficial Rodriguez',
|
||||||
|
phone: '+1-555-0456'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const [selectedEmergency, setSelectedEmergency] = useState<Emergency | null>(null);
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'critical': return 'destructive';
|
||||||
|
case 'high': return 'default';
|
||||||
|
case 'medium': return 'secondary';
|
||||||
|
default: return 'outline';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'medical': return '🏥';
|
||||||
|
case 'security': return '🚨';
|
||||||
|
case 'accident': return '🚗';
|
||||||
|
default: return '⚠️';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignOfficer = (emergencyId: string) => {
|
||||||
|
setEmergencies(prev => prev.map(e =>
|
||||||
|
e.id === emergencyId
|
||||||
|
? { ...e, status: 'assigned', assignedOfficer: 'Tu' }
|
||||||
|
: e
|
||||||
|
));
|
||||||
|
toast({
|
||||||
|
title: "Asignado",
|
||||||
|
description: "Te has asignado a esta emergencia",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = (emergencyId: string) => {
|
||||||
|
setEmergencies(prev => prev.map(e =>
|
||||||
|
e.id === emergencyId
|
||||||
|
? { ...e, status: 'resolved' }
|
||||||
|
: e
|
||||||
|
));
|
||||||
|
toast({
|
||||||
|
title: "Resuelto",
|
||||||
|
description: "La emergencia ha sido marcada como resuelta",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeSince = (timestamp: string) => {
|
||||||
|
const minutes = Math.floor((Date.now() - new Date(timestamp).getTime()) / 60000);
|
||||||
|
if (minutes < 1) return 'Hace un momento';
|
||||||
|
if (minutes < 60) return `Hace ${minutes} min`;
|
||||||
|
return `Hace ${Math.floor(minutes / 60)} hrs`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Emergencias Activas
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-red-600">
|
||||||
|
{emergencies.filter(e => e.status !== 'resolved').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Críticas
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-orange-600">
|
||||||
|
{emergencies.filter(e => e.priority === 'critical').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
En Progreso
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-blue-600">
|
||||||
|
{emergencies.filter(e => e.status === 'in_progress').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Resueltas Hoy
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-green-600">
|
||||||
|
{emergencies.filter(e => e.status === 'resolved').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Map */}
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
Mapa de Emergencias
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EmergencyMap
|
||||||
|
emergencies={emergencies}
|
||||||
|
onSelectEmergency={setSelectedEmergency}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Emergency List */}
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Alertas Activas
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||||
|
{emergencies
|
||||||
|
.filter(e => e.status !== 'resolved')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||||
|
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||||
|
})
|
||||||
|
.map((emergency) => (
|
||||||
|
<div
|
||||||
|
key={emergency.id}
|
||||||
|
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||||
|
selectedEmergency?.id === emergency.id
|
||||||
|
? 'ring-2 ring-primary bg-primary/5'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedEmergency(emergency)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">{getTypeIcon(emergency.type)}</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">{emergency.touristName}</h4>
|
||||||
|
<p className="text-sm text-gray-600">{emergency.address}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={getPriorityColor(emergency.priority)}>
|
||||||
|
{emergency.priority.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-700 mb-3">{emergency.description}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-1 text-gray-500">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{getTimeSince(emergency.timestamp)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{emergency.status === 'pending' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleAssignOfficer(emergency.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Asignarme
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{emergency.status === 'assigned' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.location.href = `tel:${emergency.phone}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleResolve(emergency.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incident Details Modal */}
|
||||||
|
{selectedEmergency && (
|
||||||
|
<IncidentDetails
|
||||||
|
emergency={selectedEmergency}
|
||||||
|
onClose={() => setSelectedEmergency(null)}
|
||||||
|
onAssign={() => handleAssignOfficer(selectedEmergency.id)}
|
||||||
|
onResolve={() => handleResolve(selectedEmergency.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmergencyDashboard;
|
||||||
204
src/pages/dashboard/politur/Reports.tsx
Normal file
204
src/pages/dashboard/politur/Reports.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FileText, Search, Filter, Download, Eye } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface Report {
|
||||||
|
id: string;
|
||||||
|
reportNumber: string;
|
||||||
|
touristName: string;
|
||||||
|
type: string;
|
||||||
|
category: string;
|
||||||
|
date: string;
|
||||||
|
status: 'open' | 'under_review' | 'closed';
|
||||||
|
officer: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Reports = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [reports] = useState<Report[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
reportNumber: 'POL-2024-001',
|
||||||
|
touristName: 'John Smith',
|
||||||
|
type: 'Robo',
|
||||||
|
category: 'Seguridad',
|
||||||
|
date: '2024-01-15',
|
||||||
|
status: 'closed',
|
||||||
|
officer: 'Oficial Rodriguez',
|
||||||
|
description: 'Robo de cartera en Zona Colonial'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
reportNumber: 'POL-2024-002',
|
||||||
|
touristName: 'Maria Garcia',
|
||||||
|
type: 'Accidente',
|
||||||
|
category: 'Tránsito',
|
||||||
|
date: '2024-01-16',
|
||||||
|
status: 'under_review',
|
||||||
|
officer: 'Oficial Martinez',
|
||||||
|
description: 'Accidente de tránsito menor en el Malecón'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
reportNumber: 'POL-2024-003',
|
||||||
|
touristName: 'Robert Johnson',
|
||||||
|
type: 'Pérdida',
|
||||||
|
category: 'Documentos',
|
||||||
|
date: '2024-01-17',
|
||||||
|
status: 'open',
|
||||||
|
officer: 'Oficial Fernandez',
|
||||||
|
description: 'Pérdida de pasaporte en área hotelera'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open':
|
||||||
|
return <Badge variant="default">Abierto</Badge>;
|
||||||
|
case 'under_review':
|
||||||
|
return <Badge variant="secondary">En Revisión</Badge>;
|
||||||
|
case 'closed':
|
||||||
|
return <Badge variant="outline">Cerrado</Badge>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredReports = reports.filter(report =>
|
||||||
|
report.touristName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
report.reportNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
report.type.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Reportes y Denuncias</h2>
|
||||||
|
<Button>
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
Nuevo Reporte
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Reportes Abiertos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-blue-600">
|
||||||
|
{reports.filter(r => r.status === 'open').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
En Revisión
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-orange-600">
|
||||||
|
{reports.filter(r => r.status === 'under_review').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Cerrados
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-green-600">
|
||||||
|
{reports.filter(r => r.status === 'closed').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por nombre, número o tipo..."
|
||||||
|
className="pl-10"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Filter className="w-4 h-4 mr-2" />
|
||||||
|
Filtros
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredReports.map((report) => (
|
||||||
|
<div
|
||||||
|
key={report.id}
|
||||||
|
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold">{report.reportNumber}</h3>
|
||||||
|
{getStatusBadge(report.status)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">{report.touristName}</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
Ver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm mb-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Tipo:</span>
|
||||||
|
<p className="font-medium">{report.type}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Categoría:</span>
|
||||||
|
<p className="font-medium">{report.category}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Fecha:</span>
|
||||||
|
<p className="font-medium">{new Date(report.date).toLocaleDateString('es-ES')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Oficial:</span>
|
||||||
|
<p className="font-medium">{report.officer}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-700">{report.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Reports;
|
||||||
13
src/types/politur.ts
Normal file
13
src/types/politur.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface Emergency {
|
||||||
|
id: string;
|
||||||
|
touristName: string;
|
||||||
|
location: { lat: number; lng: number };
|
||||||
|
address: string;
|
||||||
|
type: 'medical' | 'security' | 'accident' | 'other';
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
status: 'pending' | 'assigned' | 'in_progress' | 'resolved';
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
assignedOfficer?: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user