Implement Tourist Guide System

This commit is contained in:
gpt-engineer-app[bot]
2025-10-11 15:47:12 +00:00
parent 60b7e9a73d
commit aaac624bf9
7 changed files with 783 additions and 1 deletions

View File

@@ -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>

View File

@@ -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'),

View 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;

View 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;

View 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;

View 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
View 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;
}