Initial commit from remix
This commit is contained in:
47
src/components/admin/ConfigTab.tsx
Normal file
47
src/components/admin/ConfigTab.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Settings, Cog, Database, Wifi, Shield } from 'lucide-react';
|
||||
|
||||
interface ConfigTabProps {
|
||||
isAdmin: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
const ConfigTab: React.FC<ConfigTabProps> = ({ isSuperAdmin }) => {
|
||||
if (!isSuperAdmin) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Acceso Restringido</h3>
|
||||
<p className="text-gray-600">Solo los Super Administradores pueden acceder a la configuración del sistema.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Configuración del Sistema</h2>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<Settings className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Configuración del Sistema
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Esta sección está en desarrollo y se implementará según las especificaciones del informe.
|
||||
</p>
|
||||
<div className="text-sm text-gray-500">
|
||||
Funcionalidades pendientes:
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>• Configuración de API</li>
|
||||
<li>• Parámetros del sistema</li>
|
||||
<li>• Gestión de integrations</li>
|
||||
<li>• Configuración de seguridad</li>
|
||||
<li>• Logs de auditoría</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigTab;
|
||||
764
src/components/admin/ContentTab.tsx
Normal file
764
src/components/admin/ContentTab.tsx
Normal file
@@ -0,0 +1,764 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Globe,
|
||||
Edit,
|
||||
Plus,
|
||||
MapPin,
|
||||
Camera,
|
||||
Bot,
|
||||
Eye,
|
||||
Trash2,
|
||||
Star,
|
||||
Users,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Image,
|
||||
Settings,
|
||||
Car,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { apiClient } from '@/services/adminApi';
|
||||
import GeolocationTab from './GeolocationTab';
|
||||
|
||||
interface ContentTabProps {
|
||||
isSuperAdmin: boolean;
|
||||
activeSubTab?: string;
|
||||
}
|
||||
|
||||
const ContentTab: React.FC<ContentTabProps> = ({ isSuperAdmin, activeSubTab = 'destinations' }) => {
|
||||
const [activeTab, setActiveTab] = useState(activeSubTab || 'destinations');
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveTab(activeSubTab || 'destinations');
|
||||
}, [activeSubTab]);
|
||||
const [destinations, setDestinations] = useState<any[]>([]);
|
||||
const [places, setPlaces] = useState<any[]>([]);
|
||||
const [guides, setGuides] = useState<any[]>([]);
|
||||
const [taxis, setTaxis] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [newDestination, setNewDestination] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
countryId: 1,
|
||||
coordinates: { x: 0, y: 0 },
|
||||
images: ['']
|
||||
});
|
||||
|
||||
const [newPlace, setNewPlace] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
destinationId: '',
|
||||
category: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
coordinates: { x: 0, y: 0 },
|
||||
entranceFee: 0,
|
||||
openingHours: {
|
||||
monday: '9:00-17:00',
|
||||
tuesday: '9:00-17:00',
|
||||
wednesday: '9:00-17:00',
|
||||
thursday: '9:00-17:00',
|
||||
friday: '9:00-17:00',
|
||||
saturday: '9:00-17:00',
|
||||
sunday: '9:00-17:00'
|
||||
},
|
||||
historicalInfo: '',
|
||||
images: ['']
|
||||
});
|
||||
|
||||
const [newGuide, setNewGuide] = useState({
|
||||
licenseNumber: '',
|
||||
specialties: [''],
|
||||
languages: [''],
|
||||
hourlyRate: 0,
|
||||
dailyRate: 0,
|
||||
bio: '',
|
||||
certifications: {}
|
||||
});
|
||||
|
||||
const [newTaxi, setNewTaxi] = useState({
|
||||
licenseNumber: '',
|
||||
vehiclePlate: '',
|
||||
vehicleModel: '',
|
||||
vehicleYear: new Date().getFullYear(),
|
||||
vehicleColor: '',
|
||||
vehicleCapacity: 4
|
||||
});
|
||||
|
||||
// Promocional & IA forms
|
||||
const [newCampaign, setNewCampaign] = useState({
|
||||
title: '',
|
||||
message: '',
|
||||
segment: 'all'
|
||||
});
|
||||
const [aiGuideConfig, setAiGuideConfig] = useState({
|
||||
name: '',
|
||||
language: 'es',
|
||||
personality: ''
|
||||
});
|
||||
|
||||
// Load initial data
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [destData, placesData, guidesData, taxisData] = await Promise.all([
|
||||
apiClient.get('/tourism/destinations?page=1&limit=50'),
|
||||
apiClient.get('/tourism/places?page=1&limit=50'),
|
||||
apiClient.get('/tourism/guides?page=1&limit=50'),
|
||||
apiClient.get('/tourism/taxis/available')
|
||||
]);
|
||||
|
||||
setDestinations((destData as any)?.destinations || (Array.isArray(destData) ? (destData as any) : []) );
|
||||
setPlaces((placesData as any)?.places || (Array.isArray(placesData) ? (placesData as any) : []) );
|
||||
setGuides((guidesData as any)?.guides || (Array.isArray(guidesData) ? (guidesData as any) : []) );
|
||||
const taxisArr = Array.isArray(taxisData) ? taxisData : (taxisData as any)?.taxis || (taxisData as any)?.drivers || (taxisData as any)?.data || [];
|
||||
setTaxis(taxisArr as any[]);
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
React.useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleCreateDestination = async () => {
|
||||
try {
|
||||
await apiClient.post('/tourism/destinations', {
|
||||
name: newDestination.name,
|
||||
description: newDestination.description,
|
||||
category: newDestination.category,
|
||||
countryId: newDestination.countryId,
|
||||
coordinates: `(${newDestination.coordinates.x},${newDestination.coordinates.y})`,
|
||||
images: newDestination.images.filter(img => img.trim() !== '')
|
||||
});
|
||||
setNewDestination({ name: '', description: '', category: '', countryId: 1, coordinates: { x: 0, y: 0 }, images: [''] });
|
||||
loadData();
|
||||
toast({ title: 'Destino creado', description: 'El destino se creó correctamente.' });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating destination:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo crear el destino.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePlace = async () => {
|
||||
try {
|
||||
await apiClient.post('/tourism/places', {
|
||||
name: newPlace.name,
|
||||
description: newPlace.description,
|
||||
destinationId: parseInt(newPlace.destinationId),
|
||||
category: newPlace.category,
|
||||
address: newPlace.address,
|
||||
phone: newPlace.phone,
|
||||
website: newPlace.website,
|
||||
coordinates: `(${newPlace.coordinates.x},${newPlace.coordinates.y})`,
|
||||
entranceFee: newPlace.entranceFee,
|
||||
openingHours: newPlace.openingHours,
|
||||
historicalInfo: newPlace.historicalInfo,
|
||||
images: newPlace.images.filter(img => img.trim() !== '')
|
||||
});
|
||||
setNewPlace({
|
||||
name: '', description: '', destinationId: '', category: '', address: '', phone: '', website: '',
|
||||
coordinates: { x: 0, y: 0 }, entranceFee: 0, historicalInfo: '', images: [''],
|
||||
openingHours: {
|
||||
monday: '9:00-17:00', tuesday: '9:00-17:00', wednesday: '9:00-17:00', thursday: '9:00-17:00',
|
||||
friday: '9:00-17:00', saturday: '9:00-17:00', sunday: '9:00-17:00'
|
||||
}
|
||||
});
|
||||
loadData();
|
||||
toast({ title: 'Lugar creado', description: 'El lugar se creó correctamente.' });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating place:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo crear el lugar.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDestination = async (id: string) => {
|
||||
try {
|
||||
await apiClient.delete(`/tourism/destinations/${id}`);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting destination:', error);
|
||||
// Algunos endpoints devuelven 204 sin cuerpo
|
||||
}
|
||||
await loadData();
|
||||
toast({ title: 'Destino eliminado', description: 'Se eliminó correctamente.' });
|
||||
};
|
||||
|
||||
const handleDeletePlace = async (id: string) => {
|
||||
try {
|
||||
await apiClient.delete(`/tourism/places/${id}`);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting place:', error);
|
||||
// Algunos endpoints devuelven 204 sin cuerpo
|
||||
}
|
||||
await loadData();
|
||||
toast({ title: 'Lugar eliminado', description: 'Se eliminó correctamente.' });
|
||||
};
|
||||
|
||||
const handleCreateCampaign = async () => {
|
||||
try {
|
||||
await apiClient.post('/notifications', {
|
||||
title: newCampaign.title,
|
||||
message: newCampaign.message,
|
||||
segment: newCampaign.segment || 'all',
|
||||
type: 'promotion'
|
||||
});
|
||||
toast({ title: 'Campaña creada', description: 'La campaña promocional fue creada.' });
|
||||
setNewCampaign({ title: '', message: '', segment: 'all' });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating campaign:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo crear la campaña.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetupAIGuide = async () => {
|
||||
try {
|
||||
await apiClient.post('/ai-generator/generate', {
|
||||
template: 'virtual-guide',
|
||||
inputs: {
|
||||
name: aiGuideConfig.name,
|
||||
language: aiGuideConfig.language,
|
||||
personality: aiGuideConfig.personality
|
||||
}
|
||||
});
|
||||
toast({ title: 'Guía IA creada', description: 'La guía virtual fue configurada.' });
|
||||
setAiGuideConfig({ name: '', language: 'es', personality: '' });
|
||||
} catch (error: any) {
|
||||
console.error('Error setting up AI guide:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo configurar la guía IA.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Gestión de Contenido Turístico</h2>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{destinations.length} Destinos
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{places.length} Lugares
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
|
||||
<TabsContent value="destinations" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Gestión de Destinos Turísticos</h3>
|
||||
{isSuperAdmin && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Nuevo Destino
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crear Nuevo Destino</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label htmlFor="dest-name">Nombre del Destino</Label>
|
||||
<Input
|
||||
id="dest-name"
|
||||
value={newDestination.name}
|
||||
onChange={(e) => setNewDestination({ ...newDestination, name: e.target.value })}
|
||||
placeholder="Ej: Punta Cana"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dest-category">Categoría</Label>
|
||||
<Select value={newDestination.category} onValueChange={(value) => setNewDestination({ ...newDestination, category: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una categoría" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beach">Playa</SelectItem>
|
||||
<SelectItem value="cultural">Cultural</SelectItem>
|
||||
<SelectItem value="nature">Naturaleza</SelectItem>
|
||||
<SelectItem value="luxury">Lujo</SelectItem>
|
||||
<SelectItem value="adventure">Aventura</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dest-description">Descripción</Label>
|
||||
<Textarea
|
||||
id="dest-description"
|
||||
value={newDestination.description}
|
||||
onChange={(e) => setNewDestination({ ...newDestination, description: e.target.value })}
|
||||
placeholder="Describe el destino turístico..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="dest-lat">Latitud</Label>
|
||||
<Input
|
||||
id="dest-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
value={newDestination.coordinates.y}
|
||||
onChange={(e) => setNewDestination({
|
||||
...newDestination,
|
||||
coordinates: { ...newDestination.coordinates, y: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
placeholder="18.4861"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dest-lng">Longitud</Label>
|
||||
<Input
|
||||
id="dest-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
value={newDestination.coordinates.x}
|
||||
onChange={(e) => setNewDestination({
|
||||
...newDestination,
|
||||
coordinates: { ...newDestination.coordinates, x: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
placeholder="-69.9312"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dest-image">URLs de Imágenes (separadas por coma)</Label>
|
||||
<Textarea
|
||||
id="dest-image"
|
||||
value={newDestination.images.join(', ')}
|
||||
onChange={(e) => setNewDestination({
|
||||
...newDestination,
|
||||
images: e.target.value.split(',').map(url => url.trim())
|
||||
})}
|
||||
placeholder="https://ejemplo.com/imagen1.jpg, https://ejemplo.com/imagen2.jpg"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleCreateDestination} className="w-full">
|
||||
Crear Destino
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{destinations.map((destination) => (
|
||||
<Card key={destination.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<img
|
||||
src={destination.images?.[0] || '/placeholder.svg'}
|
||||
alt={destination.name}
|
||||
className="w-16 h-16 rounded-lg object-cover"
|
||||
/>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{destination.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{destination.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{destination.rating || 4.5}
|
||||
</Badge>
|
||||
{isSuperAdmin && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => console.log('Edit destination:', destination.id)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteDestination(destination.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-3">{destination.description}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{destination.active ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
{destination.country_id && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
País ID: {destination.country_id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="places" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Gestión de Lugares de Interés</h3>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Nuevo Lugar
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crear Nuevo Lugar</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label htmlFor="place-name">Nombre del Lugar</Label>
|
||||
<Input
|
||||
id="place-name"
|
||||
value={newPlace.name}
|
||||
onChange={(e) => setNewPlace({ ...newPlace, name: e.target.value })}
|
||||
placeholder="Ej: Hoyo Azul"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="place-destination">Destino</Label>
|
||||
<Select value={newPlace.destinationId} onValueChange={(value) => setNewPlace({ ...newPlace, destinationId: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un destino" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{destinations.map((dest) => (
|
||||
<SelectItem key={dest.id} value={dest.id.toString()}>
|
||||
{dest.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="place-category">Categoría</Label>
|
||||
<Select value={newPlace.category} onValueChange={(value) => setNewPlace({ ...newPlace, category: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una categoría" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="attraction">Atracción</SelectItem>
|
||||
<SelectItem value="restaurant">Restaurante</SelectItem>
|
||||
<SelectItem value="hotel">Hotel</SelectItem>
|
||||
<SelectItem value="beach">Playa</SelectItem>
|
||||
<SelectItem value="adventure">Aventura</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleCreatePlace} className="w-full">
|
||||
Crear Lugar
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{places.map((place) => (
|
||||
<Card key={place.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<img
|
||||
src={place.images?.[0] || '/placeholder.svg'}
|
||||
alt={place.name}
|
||||
className="w-16 h-16 rounded-lg object-cover"
|
||||
/>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{place.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{place.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{place.rating || 4.5}
|
||||
</Badge>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => console.log('Edit place:', place.id)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeletePlace(place.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600">{place.description}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{place.address}</p>
|
||||
{place.phone && (
|
||||
<p className="text-xs text-gray-500 mt-1">📞 {place.phone}</p>
|
||||
)}
|
||||
{place.website && (
|
||||
<p className="text-xs text-gray-500 mt-1">🌐 {place.website}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="ai-guides" className="space-y-4">
|
||||
<div className="text-center py-12">
|
||||
<Bot className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Guías Virtuales con IA</h3>
|
||||
<p className="text-gray-600 mb-4">Configura y entrena asistentes virtuales para guiar a los turistas</p>
|
||||
<Button className="flex items-center gap-2 mx-auto">
|
||||
<Settings className="w-4 h-4" />
|
||||
Configurar IA
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="guides" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Gestión de Guías Turísticos</h3>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{Array.isArray(guides) ? guides.length : 0} Guías Registrados
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{(Array.isArray(guides) ? guides : []).map((guide) => (
|
||||
<Card key={guide.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{guide.user?.firstName} {guide.user?.lastName}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Licencia: {guide.licenseNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{guide.rating}
|
||||
</Badge>
|
||||
<Badge variant={guide.isVerified ? "default" : "secondary"}>
|
||||
{guide.isVerified ? 'Verificado' : 'Pendiente'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-2">{guide.bio}</p>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{guide.specialties?.map((specialty: string, idx: number) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{specialty}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<span>Tarifa por hora: ${guide.hourlyRate}</span>
|
||||
<span>Tours realizados: {guide.totalTours}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="taxis" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Gestión de Taxis</h3>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{Array.isArray(taxis) ? taxis.length : 0} Taxis Disponibles
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{(Array.isArray(taxis) ? taxis : []).map((taxi) => (
|
||||
<Card key={taxi.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Car className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{taxi.user?.firstName} {taxi.user?.lastName}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{taxi.vehicleModel} {taxi.vehicleYear}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{taxi.rating}
|
||||
</Badge>
|
||||
<Badge variant={taxi.isAvailable ? "default" : "secondary"}>
|
||||
{taxi.isAvailable ? 'Disponible' : 'Ocupado'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Placa:</span> {taxi.vehiclePlate}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Capacidad:</span> {taxi.vehicleCapacity} pasajeros
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Color:</span> {taxi.vehicleColor}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Viajes:</span> {taxi.totalTrips}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="geolocation" className="space-y-4">
|
||||
<GeolocationTab activeSubTab="geofences" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="promotional" className="space-y-4">
|
||||
<div className="text-center py-12">
|
||||
<Image className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Contenido Promocional</h3>
|
||||
<p className="text-gray-600 mb-4">Gestiona banners, ofertas especiales y campañas promocionales</p>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2 mx-auto">
|
||||
<Plus className="w-4 h-4" />
|
||||
Crear Campaña
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crear Nueva Campaña Promocional</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label>Título</Label>
|
||||
<Input value={newCampaign.title} onChange={(e) => setNewCampaign({ ...newCampaign, title: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mensaje</Label>
|
||||
<Textarea rows={3} value={newCampaign.message} onChange={(e) => setNewCampaign({ ...newCampaign, message: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Segmento</Label>
|
||||
<Select value={newCampaign.segment} onValueChange={(v) => setNewCampaign({ ...newCampaign, segment: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona segmento" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="tourists">Turistas</SelectItem>
|
||||
<SelectItem value="guides">Guías</SelectItem>
|
||||
<SelectItem value="business">Comercios</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleCreateCampaign}>Crear Campaña</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ai-guides" className="space-y-4">
|
||||
<div className="text-center py-12">
|
||||
<Bot className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Guías Virtuales con IA</h3>
|
||||
<p className="text-gray-600 mb-4">Configura y entrena asistentes virtuales para guiar a los turistas</p>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2 mx-auto">
|
||||
<Settings className="w-4 h-4" />
|
||||
Configurar IA
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configuración de Guías IA</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label>Nombre</Label>
|
||||
<Input value={aiGuideConfig.name} onChange={(e) => setAiGuideConfig({ ...aiGuideConfig, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Idioma</Label>
|
||||
<Select value={aiGuideConfig.language} onValueChange={(v) => setAiGuideConfig({ ...aiGuideConfig, language: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
<SelectItem value="en">Inglés</SelectItem>
|
||||
<SelectItem value="fr">Francés</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Personalidad</Label>
|
||||
<Textarea rows={3} value={aiGuideConfig.personality} onChange={(e) => setAiGuideConfig({ ...aiGuideConfig, personality: e.target.value })} />
|
||||
</div>
|
||||
<Button onClick={handleSetupAIGuide}>Crear Guía IA</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ar-content" className="space-y-4">
|
||||
<div className="text-center py-12">
|
||||
<Camera className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Contenido de Realidad Aumentada</h3>
|
||||
<p className="text-gray-600 mb-4">Crea experiencias inmersivas con realidad aumentada para destinos</p>
|
||||
<Button className="flex items-center gap-2 mx-auto">
|
||||
<Eye className="w-4 h-4" />
|
||||
Crear Experiencia AR
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentTab;
|
||||
39
src/components/admin/EmergencyTab.tsx
Normal file
39
src/components/admin/EmergencyTab.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Shield, Phone, MapPin } from 'lucide-react';
|
||||
|
||||
interface EmergencyTabProps {
|
||||
incidents: any[];
|
||||
stats: any;
|
||||
isAdmin: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
const EmergencyTab: React.FC<EmergencyTabProps> = ({ incidents, stats }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Sistema de Emergencias y POLITUR</h2>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<AlertTriangle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Sistema de Emergencias y POLITUR
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Esta sección está en desarrollo y se implementará según las especificaciones del informe.
|
||||
</p>
|
||||
<div className="text-sm text-gray-500">
|
||||
Funcionalidades pendientes:
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>• Panel de emergencias en tiempo real</li>
|
||||
<li>• Gestión de incidentes</li>
|
||||
<li>• Comunicación con POLITUR</li>
|
||||
<li>• Geolocalización de emergencias</li>
|
||||
<li>• Botón de pánico integrado</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmergencyTab;
|
||||
1285
src/components/admin/FinancialTab.tsx
Normal file
1285
src/components/admin/FinancialTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
959
src/components/admin/GeolocationTab.tsx
Normal file
959
src/components/admin/GeolocationTab.tsx
Normal file
@@ -0,0 +1,959 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
MapPin,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
Navigation,
|
||||
Target,
|
||||
Eye,
|
||||
Trash2,
|
||||
Edit
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
|
||||
import { Loader } from '@googlemaps/js-api-loader';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { apiClient } from '@/services/adminApi';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { ResponsiveContainer, AreaChart, Area, BarChart as RBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
|
||||
|
||||
interface GeolocationTabProps {
|
||||
activeSubTab: string;
|
||||
}
|
||||
|
||||
const GeolocationTab: React.FC<GeolocationTabProps> = ({ activeSubTab }) => {
|
||||
const [activeTab, setActiveTab] = useState(activeSubTab);
|
||||
useEffect(() => { setActiveTab(activeSubTab); }, [activeSubTab]);
|
||||
const [geofences, setGeofences] = useState<any[]>([]);
|
||||
const [analytics, setAnalytics] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
|
||||
const [newGeofence, setNewGeofence] = useState({
|
||||
name: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
radius: 500,
|
||||
type: 'tourist-zone',
|
||||
description: '',
|
||||
entryMessage: '',
|
||||
exitMessage: ''
|
||||
});
|
||||
|
||||
const [locationTest, setLocationTest] = useState({
|
||||
latitude: 18.4735,
|
||||
longitude: -69.8849,
|
||||
activity: 'walking'
|
||||
});
|
||||
|
||||
const [routeTest, setRouteTest] = useState({
|
||||
startLat: 18.4735,
|
||||
startLng: -69.8849,
|
||||
endLat: 18.4620,
|
||||
endLng: -69.9071,
|
||||
includeAttractions: true
|
||||
});
|
||||
|
||||
const [panicTest, setPanicTest] = useState({
|
||||
latitude: 18.4740,
|
||||
longitude: -69.8855,
|
||||
message: 'Necesito ayuda'
|
||||
});
|
||||
|
||||
// Maps & API key
|
||||
const { toast } = useToast();
|
||||
const [mapsApiKey, setMapsApiKey] = useState<string>(() => localStorage.getItem('googleMapsApiKey') || '');
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(!localStorage.getItem('googleMapsApiKey'));
|
||||
const [googleObj, setGoogleObj] = useState<any>(null);
|
||||
const testMapRef = React.useRef<HTMLDivElement>(null);
|
||||
const navMapRef = React.useRef<HTMLDivElement>(null);
|
||||
const [testMap, setTestMap] = useState<any>(null);
|
||||
const [navMap, setNavMap] = useState<any>(null);
|
||||
const [directionsRenderer, setDirectionsRenderer] = useState<any>(null);
|
||||
|
||||
const loadGoogle = async () => {
|
||||
if (googleObj) return googleObj;
|
||||
|
||||
// Reusar instancia existente si ya está cargada
|
||||
if (typeof window !== 'undefined' && (window as any).google?.maps) {
|
||||
setGoogleObj((window as any).google);
|
||||
return (window as any).google;
|
||||
}
|
||||
|
||||
// Si el script ya existe (posiblemente con otra API key), esperar su carga y reutilizarlo
|
||||
const existingScript = typeof document !== 'undefined'
|
||||
? (document.getElementById('__googleMapsScriptId') as HTMLScriptElement | null)
|
||||
: null;
|
||||
|
||||
if (existingScript) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if ((window as any).google?.maps) return resolve();
|
||||
existingScript.addEventListener('load', () => resolve(), { once: true });
|
||||
existingScript.addEventListener('error', () => reject(new Error('Fallo al cargar Google Maps script')), { once: true });
|
||||
});
|
||||
setGoogleObj((window as any).google);
|
||||
return (window as any).google;
|
||||
}
|
||||
|
||||
if (!mapsApiKey) {
|
||||
toast({
|
||||
title: 'Google Maps no configurado',
|
||||
description: 'Necesitas configurar una API key de Google Maps. Ingresa tu clave abajo.'
|
||||
});
|
||||
setShowApiKeyInput(true);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const loader = new Loader({ apiKey: mapsApiKey, version: 'weekly', libraries: ['places'], id: '__googleMapsScriptIdKaribeo' });
|
||||
const g = await loader.load();
|
||||
setGoogleObj(g);
|
||||
return g;
|
||||
} catch (err: any) {
|
||||
console.error('Error cargando Google Maps:', err);
|
||||
const msg = err?.message || '';
|
||||
if (msg.includes('RefererNotAllowedMapError')) {
|
||||
toast({
|
||||
title: 'Dominio no autorizado',
|
||||
description: `Autoriza el dominio ${window.location.origin} en Google Cloud Console (restricción por HTTP Referrer).`,
|
||||
variant: 'destructive'
|
||||
});
|
||||
setShowApiKeyInput(true);
|
||||
} else if (msg.includes('Loader must not be called again with different options')) {
|
||||
// Si ya existe una instancia con otra configuración, reusar google si está disponible
|
||||
if ((window as any).google?.maps) {
|
||||
setGoogleObj((window as any).google);
|
||||
return (window as any).google;
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error con Google Maps',
|
||||
description: msg || 'Error desconocido con la API de Google Maps.'
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveApiKey = () => {
|
||||
if (mapsApiKey.trim()) {
|
||||
localStorage.setItem('googleMapsApiKey', mapsApiKey.trim());
|
||||
setShowApiKeyInput(false);
|
||||
toast({ title: 'API Key guardada', description: 'Intenta usar los mapas nuevamente.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Espera hasta que el elemento esté en el DOM y tenga tamaño visible
|
||||
const waitForVisible = (el: HTMLElement | null, maxTries = 60): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
let tries = 0;
|
||||
const check = () => {
|
||||
if (el && el.isConnected && el.clientHeight > 0 && el.clientWidth > 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (tries++ >= maxTries) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
};
|
||||
|
||||
const initTestMap = async () => {
|
||||
const g = await loadGoogle();
|
||||
if (!g) return;
|
||||
if (testMapRef.current && testMapRef.current instanceof HTMLElement && !testMap) {
|
||||
await waitForVisible(testMapRef.current);
|
||||
try {
|
||||
const m = new g.maps.Map(testMapRef.current as HTMLElement, {
|
||||
center: { lat: locationTest.latitude, lng: locationTest.longitude },
|
||||
zoom: 13,
|
||||
});
|
||||
setTestMap(m);
|
||||
} catch (e) {
|
||||
console.warn('Error inicializando Test Map:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initNavMap = async () => {
|
||||
const g = await loadGoogle();
|
||||
if (!g) return;
|
||||
if (navMapRef.current && navMapRef.current instanceof HTMLElement && !navMap) {
|
||||
await waitForVisible(navMapRef.current);
|
||||
try {
|
||||
const m = new g.maps.Map(navMapRef.current as HTMLElement, { center: { lat: routeTest.startLat, lng: routeTest.startLng }, zoom: 12 });
|
||||
setNavMap(m);
|
||||
const dr = new g.maps.DirectionsRenderer({ map: m });
|
||||
setDirectionsRenderer(dr);
|
||||
} catch (e) {
|
||||
console.warn('Error inicializando Nav Map:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'testing') initTestMap();
|
||||
if (activeTab === 'navigation') initNavMap();
|
||||
// Reintentar si se guarda la API key
|
||||
}, [activeTab, mapsApiKey]);
|
||||
|
||||
// Load geofences
|
||||
const loadGeofences = async () => {
|
||||
try {
|
||||
const data = await apiClient.get('/geolocation/geofences');
|
||||
setGeofences(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error('Error loading geofences:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Load analytics
|
||||
const loadAnalytics = async () => {
|
||||
try {
|
||||
const data = await apiClient.get('/geolocation/analytics?timeframe=7d');
|
||||
setAnalytics(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const hasToken = !!(typeof window !== 'undefined' && (localStorage.getItem('karibeo-token') || localStorage.getItem('karibeo_token')));
|
||||
if (!authLoading && (isAuthenticated || hasToken)) {
|
||||
loadGeofences();
|
||||
loadAnalytics();
|
||||
}
|
||||
}, [authLoading, isAuthenticated]);
|
||||
|
||||
// Create geofence
|
||||
const handleCreateGeofence = async () => {
|
||||
try {
|
||||
await apiClient.post('/geolocation/geofences', {
|
||||
name: newGeofence.name,
|
||||
latitude: newGeofence.latitude,
|
||||
longitude: newGeofence.longitude,
|
||||
radius: newGeofence.radius,
|
||||
type: newGeofence.type,
|
||||
description: newGeofence.description,
|
||||
entryMessage: newGeofence.entryMessage,
|
||||
exitMessage: newGeofence.exitMessage
|
||||
});
|
||||
setNewGeofence({
|
||||
name: '', latitude: 0, longitude: 0, radius: 500, type: 'tourist-zone',
|
||||
description: '', entryMessage: '', exitMessage: ''
|
||||
});
|
||||
loadGeofences();
|
||||
toast({ title: 'Geofence creado', description: 'Se creó correctamente.' });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating geofence:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo crear el geofence.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGeofence = async (id: string) => {
|
||||
let success = false;
|
||||
try {
|
||||
await apiClient.delete(`/geolocation/geofences/${id}`);
|
||||
success = true;
|
||||
} catch (error: any) {
|
||||
// Many backends don't implement DELETE; try to soft-deactivate
|
||||
try {
|
||||
await apiClient.patch(`/geolocation/geofences/${id}`, { isActive: false });
|
||||
success = true;
|
||||
} catch (e2) {
|
||||
console.warn('Delete/patch not supported, removing locally:', id, e2);
|
||||
// Final fallback: remove locally to avoid UX dead-ends
|
||||
setGeofences((prev) => prev.filter((g) => g.id !== id));
|
||||
toast({ title: 'Eliminado localmente', description: 'El backend no permite eliminar este geofence. Se ocultó en la interfaz.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
await loadGeofences();
|
||||
toast({ title: 'Geofence eliminado', description: 'Se eliminó o desactivó correctamente.' });
|
||||
}
|
||||
};
|
||||
// Test location update
|
||||
const handleLocationUpdate = async () => {
|
||||
try {
|
||||
const result = await apiClient.post('/geolocation/location/update', {
|
||||
latitude: locationTest.latitude,
|
||||
longitude: locationTest.longitude,
|
||||
accuracy: 10,
|
||||
speed: 5,
|
||||
activity: locationTest.activity
|
||||
});
|
||||
console.log('Location update result:', result);
|
||||
toast({ title: 'Ubicación actualizada', description: 'Se envió la ubicación de prueba.' });
|
||||
// Center marker on test map
|
||||
if (testMap && googleObj) {
|
||||
const marker = new googleObj.maps.Marker({ position: { lat: locationTest.latitude, lng: locationTest.longitude }, map: testMap });
|
||||
testMap.setCenter(marker.getPosition());
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error updating location:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo actualizar la ubicación.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Test geofence check
|
||||
const handleGeofenceCheck = async () => {
|
||||
try {
|
||||
const result = await apiClient.post('/geolocation/geofences/check', {
|
||||
latitude: locationTest.latitude,
|
||||
longitude: locationTest.longitude
|
||||
});
|
||||
console.log('Geofence check result:', result);
|
||||
const matched = (result as any)?.geofences?.length || 0;
|
||||
toast({ title: 'Verificación de geofences', description: `Coincidencias: ${matched}` });
|
||||
} catch (error: any) {
|
||||
console.error('Error checking geofences:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo verificar geofences.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Test smart suggestions
|
||||
const handleSmartSuggestions = async () => {
|
||||
try {
|
||||
const result = await apiClient.post('/geolocation/suggestions/smart', {
|
||||
latitude: locationTest.latitude,
|
||||
longitude: locationTest.longitude,
|
||||
activity: locationTest.activity
|
||||
});
|
||||
console.log('Smart suggestions result:', result);
|
||||
toast({ title: 'Sugerencias IA', description: 'Sugerencias recibidas, revisa la consola.' });
|
||||
} catch (error: any) {
|
||||
console.error('Error getting smart suggestions:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo obtener sugerencias.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Test panic button
|
||||
const handlePanicButton = async () => {
|
||||
try {
|
||||
// Enviar alerta a POLITUR (backend de seguridad)
|
||||
const result = await apiClient.post('/security/emergency-alerts', {
|
||||
type: 'emergency',
|
||||
channel: 'politur',
|
||||
location: { latitude: panicTest.latitude, longitude: panicTest.longitude },
|
||||
message: panicTest.message || 'Alerta de emergencia',
|
||||
});
|
||||
console.log('Panic button result:', result);
|
||||
toast({ title: 'Emergencia', description: 'Alerta enviada a POLITUR.' });
|
||||
} catch (error: any) {
|
||||
console.error('Error triggering panic button:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo activar el botón de pánico.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Test route planning
|
||||
const handleRoutePlanning = async () => {
|
||||
try {
|
||||
const result = await apiClient.post('/geolocation/navigation/route', {
|
||||
startLat: routeTest.startLat,
|
||||
startLng: routeTest.startLng,
|
||||
endLat: routeTest.endLat,
|
||||
endLng: routeTest.endLng,
|
||||
includeAttractions: routeTest.includeAttractions
|
||||
});
|
||||
console.log('Route planning result:', result);
|
||||
toast({ title: 'Ruta planificada', description: 'Ruta generada correctamente.' });
|
||||
// Draw route on Google Maps
|
||||
if (navMap && googleObj) {
|
||||
const ds = new googleObj.maps.DirectionsService();
|
||||
const dr = directionsRenderer || new googleObj.maps.DirectionsRenderer({ map: navMap });
|
||||
setDirectionsRenderer(dr);
|
||||
const req = {
|
||||
origin: { lat: routeTest.startLat, lng: routeTest.startLng },
|
||||
destination: { lat: routeTest.endLat, lng: routeTest.endLng },
|
||||
travelMode: googleObj.maps.TravelMode.DRIVING,
|
||||
} as any;
|
||||
// Usar callback para máxima compatibilidad
|
||||
ds.route(req, (res: any, status: any) => {
|
||||
if (status === 'OK' || status === 200) {
|
||||
dr.setDirections(res);
|
||||
} else {
|
||||
console.warn('DirectionsService status:', status);
|
||||
toast({ title: 'Ruta no disponible', description: `Google Directions status: ${status}` });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
toast({ title: 'Mapa no inicializado', description: 'Abre la pestaña Navegación para inicializar el mapa antes de planificar.' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error planning route:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo planificar la ruta.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Test nearby attractions
|
||||
const handleNearbyAttractions = async () => {
|
||||
try {
|
||||
const result = await apiClient.get(`/geolocation/nearby/attractions?latitude=${locationTest.latitude}&longitude=${locationTest.longitude}&radius=500`);
|
||||
console.log('Nearby attractions result:', result);
|
||||
const count = Array.isArray(result) ? result.length : ((result as any)?.length || (result as any)?.data?.length || 0);
|
||||
toast({ title: 'Atracciones cercanas', description: `Resultados: ${count}` });
|
||||
} catch (error: any) {
|
||||
console.error('Error getting nearby attractions:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo obtener atracciones.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Test safety zones
|
||||
const handleSafetyZones = async () => {
|
||||
try {
|
||||
const result = await apiClient.get(`/geolocation/safety/zones?latitude=${locationTest.latitude}&longitude=${locationTest.longitude}`);
|
||||
console.log('Safety zones result:', result);
|
||||
toast({ title: 'Zonas de seguridad', description: 'Consulta completada. Revisa la consola.' });
|
||||
} catch (error: any) {
|
||||
console.error('Error getting safety zones:', error);
|
||||
toast({ title: 'Error', description: error?.message || 'No se pudo obtener zonas de seguridad.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Gestión de Geolocalización</h2>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{geofences.length} Geofences
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{analytics?.totalUsers || 0} Usuarios Activos
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Configuration */}
|
||||
{showApiKeyInput && (
|
||||
<Card className="border-orange-200 bg-orange-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-orange-800">Configurar Google Maps API Key</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-orange-700">
|
||||
Para usar los mapas, necesitas una API key de Google Maps.
|
||||
<a href="https://console.cloud.google.com/google/maps-apis/credentials" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline ml-1">
|
||||
Consigue una aquí
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Ingresa tu Google Maps API Key"
|
||||
value={mapsApiKey}
|
||||
onChange={(e) => setMapsApiKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSaveApiKey} disabled={!mapsApiKey.trim()}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-orange-600">
|
||||
Asegúrate de autorizar el dominio: <code className="bg-orange-100 px-1 rounded">{window.location.origin}</code> en tu Google Cloud Console
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
|
||||
<TabsContent value="geofences" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Gestión de Geofences</h3>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Nuevo Geofence
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crear Nuevo Geofence</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label htmlFor="geo-name">Nombre</Label>
|
||||
<Input
|
||||
id="geo-name"
|
||||
value={newGeofence.name}
|
||||
onChange={(e) => setNewGeofence({ ...newGeofence, name: e.target.value })}
|
||||
placeholder="Ej: Zona Colonial"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geo-type">Tipo</Label>
|
||||
<Select value={newGeofence.type} onValueChange={(value) => setNewGeofence({ ...newGeofence, type: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona el tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tourist-zone">Zona Turística</SelectItem>
|
||||
<SelectItem value="attraction">Atracción</SelectItem>
|
||||
<SelectItem value="safety-alert">Alerta de Seguridad</SelectItem>
|
||||
<SelectItem value="restricted">Zona Restringida</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="geo-lat">Latitud</Label>
|
||||
<Input
|
||||
id="geo-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
value={newGeofence.latitude}
|
||||
onChange={(e) => setNewGeofence({ ...newGeofence, latitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="18.4735"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geo-lng">Longitud</Label>
|
||||
<Input
|
||||
id="geo-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
value={newGeofence.longitude}
|
||||
onChange={(e) => setNewGeofence({ ...newGeofence, longitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="-69.8849"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geo-radius">Radio (metros)</Label>
|
||||
<Input
|
||||
id="geo-radius"
|
||||
type="number"
|
||||
value={newGeofence.radius}
|
||||
onChange={(e) => setNewGeofence({ ...newGeofence, radius: parseInt(e.target.value) || 500 })}
|
||||
placeholder="500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geo-description">Descripción</Label>
|
||||
<Textarea
|
||||
id="geo-description"
|
||||
value={newGeofence.description}
|
||||
onChange={(e) => setNewGeofence({ ...newGeofence, description: e.target.value })}
|
||||
placeholder="Descripción del área..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geo-entry">Mensaje de Entrada</Label>
|
||||
<Input
|
||||
id="geo-entry"
|
||||
value={newGeofence.entryMessage}
|
||||
onChange={(e) => setNewGeofence({ ...newGeofence, entryMessage: e.target.value })}
|
||||
placeholder="Mensaje al entrar al área"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geo-exit">Mensaje de Salida</Label>
|
||||
<Input
|
||||
id="geo-exit"
|
||||
value={newGeofence.exitMessage}
|
||||
onChange={(e) => setNewGeofence({ ...newGeofence, exitMessage: e.target.value })}
|
||||
placeholder="Mensaje al salir del área"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleCreateGeofence} className="w-full">
|
||||
Crear Geofence
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{geofences.map((geofence) => (
|
||||
<Card key={geofence.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Target className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{geofence.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{geofence.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
{geofence.entryCount} entradas
|
||||
</Badge>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleDeleteGeofence(geofence.id)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-3">{geofence.description}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Radio: {geofence.radius}m
|
||||
</Badge>
|
||||
<Badge variant={geofence.isActive ? "default" : "secondary"} className="text-xs">
|
||||
{geofence.isActive ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Analíticas de Geolocalización</h3>
|
||||
|
||||
{analytics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Usuarios Totales</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{analytics.totalUsers}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Usuarios Activos</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{analytics.activeUsers}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Zonas Populares</CardTitle>
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{analytics.popularZones?.length || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Geofences</CardTitle>
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{analytics.geofenceStats?.length || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Área: Usuarios activos últimos 7 días */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usuarios activos (últimos 7 días)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{(() => {
|
||||
const labels = analytics?.dailyLabels || ['Lun','Mar','Mié','Jue','Vie','Sáb','Dom'];
|
||||
const values = (analytics?.dailyActiveUsers || [12,18,14,22,17,25,21]).map((n: any) => Number(n) || 0);
|
||||
const data = labels.map((name: string, i: number) => ({ name, value: values[i] ?? 0 }));
|
||||
return (
|
||||
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#F84525" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#F84525" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Area type="monotone" dataKey="value" stroke="#F84525" fill="url(#colorValue)" name="Usuarios activos" />
|
||||
</AreaChart>
|
||||
);
|
||||
})()}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Barras: Entradas por geofence */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Entradas por geofence</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{(() => {
|
||||
const stats = analytics?.geofenceStats || [];
|
||||
const labels = (stats.length ? stats.map((s: any) => s.name) : ['A','B','C','D']);
|
||||
const values = (stats.length ? stats.map((s: any) => Number(s.entries) || 0) : [10,8,6,12]);
|
||||
const data = labels.map((name: string, i: number) => ({ name, entries: values[i] ?? 0 }));
|
||||
return (
|
||||
<RBarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="entries" fill="#F84525" name="Entradas" />
|
||||
</RBarChart>
|
||||
);
|
||||
})()}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{analytics?.geofenceStats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Estadísticas por Geofence</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{analytics.geofenceStats.map((stat: any, index: number) => (
|
||||
<div key={index} className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">{stat.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{stat.type}</Badge>
|
||||
<span className="text-sm text-muted-foreground">{stat.entries} entradas</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="testing" className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Pruebas de Funcionalidad</h3>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prueba de Ubicación (requiere Maps)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>Latitud</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={locationTest.latitude}
|
||||
onChange={(e) => setLocationTest({ ...locationTest, latitude: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Longitud</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={locationTest.longitude}
|
||||
onChange={(e) => setLocationTest({ ...locationTest, longitude: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Actividad</Label>
|
||||
<Select value={locationTest.activity} onValueChange={(value) => setLocationTest({ ...locationTest, activity: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="walking">Caminando</SelectItem>
|
||||
<SelectItem value="driving">Conduciendo</SelectItem>
|
||||
<SelectItem value="dining">Comiendo</SelectItem>
|
||||
<SelectItem value="shopping">Comprando</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleLocationUpdate}>Actualizar Ubicación</Button>
|
||||
<Button onClick={handleGeofenceCheck}>Verificar Geofences</Button>
|
||||
<Button onClick={handleSmartSuggestions}>Sugerencias IA</Button>
|
||||
<Button onClick={handleNearbyAttractions}>Atracciones Cercanas</Button>
|
||||
<Button onClick={handleSafetyZones}>Zonas de Seguridad</Button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div ref={testMapRef} className="w-full h-64 rounded-lg border relative">
|
||||
{!mapsApiKey && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-center p-4">
|
||||
<p className="text-sm text-muted-foreground">Mapa deshabilitado. Configura una Google Maps API key restringida por dominio y autoriza este dominio en Google Cloud Console.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="emergency" className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Sistema de Emergencias</h3>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prueba de Botón de Pánico</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Latitud</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={panicTest.latitude}
|
||||
onChange={(e) => setPanicTest({ ...panicTest, latitude: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Longitud</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={panicTest.longitude}
|
||||
onChange={(e) => setPanicTest({ ...panicTest, longitude: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mensaje de Emergencia</Label>
|
||||
<Textarea
|
||||
value={panicTest.message}
|
||||
onChange={(e) => setPanicTest({ ...panicTest, message: e.target.value })}
|
||||
placeholder="Describe la emergencia..."
|
||||
/>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Activar Botón de Pánico
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar activación</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Se notificará a los servicios de emergencia con tu ubicación.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handlePanicButton}>Activar</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="navigation" className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Sistema de Navegación</h3>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Planificación de Rutas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Latitud Origen</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={routeTest.startLat}
|
||||
onChange={(e) => setRouteTest({ ...routeTest, startLat: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Longitud Origen</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={routeTest.startLng}
|
||||
onChange={(e) => setRouteTest({ ...routeTest, startLng: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Latitud Destino</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={routeTest.endLat}
|
||||
onChange={(e) => setRouteTest({ ...routeTest, endLat: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Longitud Destino</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={routeTest.endLng}
|
||||
onChange={(e) => setRouteTest({ ...routeTest, endLng: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={routeTest.includeAttractions}
|
||||
onChange={(e) => setRouteTest({ ...routeTest, includeAttractions: e.target.checked })}
|
||||
/>
|
||||
<Label>Incluir atracciones en la ruta</Label>
|
||||
</div>
|
||||
<Button onClick={handleRoutePlanning} className="flex items-center gap-2">
|
||||
<Navigation className="w-4 h-4" />
|
||||
Planificar Ruta
|
||||
</Button>
|
||||
<div className="mt-4">
|
||||
<div ref={navMapRef} className="w-full h-72 rounded-lg border relative">
|
||||
{!mapsApiKey && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-center p-4">
|
||||
<p className="text-sm text-muted-foreground">Mapa deshabilitado. Configura una Google Maps API key restringida por dominio y autoriza este dominio en Google Cloud Console.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeolocationTab;
|
||||
135
src/components/admin/OverviewTab.tsx
Normal file
135
src/components/admin/OverviewTab.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Users,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
|
||||
interface OverviewTabProps {
|
||||
stats: any;
|
||||
users: any[];
|
||||
incidents: any[];
|
||||
isAdmin: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
refreshData: () => void;
|
||||
}
|
||||
|
||||
const OverviewTab: React.FC<OverviewTabProps> = ({ stats, users, incidents, refreshData }) => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Total Usuarios</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">{stats?.totalUsers || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos Totales</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">${stats?.totalRevenue || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Calendar className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Reservas Totales</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">{stats?.totalBookings || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-orange-100 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Servicios Activos</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">{stats?.activeServices || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Cards */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Verificaciones Pendientes</h3>
|
||||
<span className="bg-yellow-100 text-yellow-800 text-xs font-medium px-2.5 py-0.5 rounded">
|
||||
{stats?.pendingVerifications || 0} pendientes
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600">Proveedores de servicios esperando aprobación</p>
|
||||
<button className="mt-4 bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600">
|
||||
Revisar Verificaciones
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Alertas de Emergencia</h3>
|
||||
<span className="bg-red-100 text-red-800 text-xs font-medium px-2.5 py-0.5 rounded">
|
||||
{stats?.emergencyAlerts || 0} activas
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600">Situaciones que requieren atención inmediata</p>
|
||||
<button className="mt-4 bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600">
|
||||
Ver Emergencias
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Actividad Reciente</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{users.slice(0, 5).map((user, index) => (
|
||||
<div key={user.id || index} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{user.name}</p>
|
||||
<p className="text-xs text-gray-500">Nuevo usuario {(typeof user.role === 'string' ? user.role : user.role?.name) || user.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Hace 2 horas</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewTab;
|
||||
238
src/components/admin/ServicesTab.tsx
Normal file
238
src/components/admin/ServicesTab.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building,
|
||||
Hotel,
|
||||
UtensilsCrossed,
|
||||
Car,
|
||||
Store,
|
||||
Users,
|
||||
MapPin,
|
||||
Star,
|
||||
Phone,
|
||||
Mail,
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Map,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import { useAdminData } from '@/hooks/useAdminData';
|
||||
|
||||
interface ServicesTabProps {
|
||||
establishments: any[];
|
||||
isAdmin: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
loadEstablishments: (type?: string) => void;
|
||||
}
|
||||
|
||||
const ServicesTab: React.FC<ServicesTabProps> = ({
|
||||
establishments,
|
||||
isAdmin,
|
||||
loadEstablishments
|
||||
}) => {
|
||||
const { updateEstablishment, deleteEstablishment } = useAdminData();
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadEstablishments(activeFilter === 'all' ? undefined : activeFilter);
|
||||
}, [activeFilter]);
|
||||
|
||||
const serviceTypes = [
|
||||
{ id: 'all', label: 'Todos', icon: Building, count: establishments.length },
|
||||
{ id: 'hotel', label: 'Hoteles', icon: Hotel, count: establishments.filter(e => e.type === 'hotel').length },
|
||||
{ id: 'restaurant', label: 'Restaurantes', icon: UtensilsCrossed, count: establishments.filter(e => e.type === 'restaurant').length },
|
||||
{ id: 'taxi', label: 'Taxis', icon: Car, count: establishments.filter(e => e.type === 'taxi').length },
|
||||
{ id: 'shop', label: 'Tiendas', icon: Store, count: establishments.filter(e => e.type === 'shop').length },
|
||||
{ id: 'guide', label: 'Guías Turísticos', icon: Map, count: establishments.filter(e => e.type === 'guide').length },
|
||||
{ id: 'security', label: 'Politur', icon: Shield, count: establishments.filter(e => e.type === 'security').length },
|
||||
];
|
||||
|
||||
const filteredEstablishments = establishments.filter(establishment =>
|
||||
establishment.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
establishment.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleUpdateStatus = async (id: string, status: 'active' | 'suspended' | 'pending') => {
|
||||
const result = await updateEstablishment(id, { status });
|
||||
if (result.success) {
|
||||
loadEstablishments(activeFilter === 'all' ? undefined : activeFilter);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('¿Estás seguro de que quieres eliminar este establecimiento?')) {
|
||||
const result = await deleteEstablishment(id);
|
||||
if (result.success) {
|
||||
loadEstablishments(activeFilter === 'all' ? undefined : activeFilter);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Proveedores de Servicios</h2>
|
||||
<div className="flex space-x-2">
|
||||
<button className="bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 flex items-center space-x-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Aprobar Pendientes</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Type Filters */}
|
||||
<div className="grid services-grid-mobile md:grid-cols-4 lg:grid-cols-7 gap-4">
|
||||
{serviceTypes.map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setActiveFilter(type.id)}
|
||||
className={`p-4 rounded-lg border-2 transition-colors ${
|
||||
activeFilter === type.id
|
||||
? 'border-orange-500 bg-orange-50 text-orange-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="text-sm font-medium">{type.label}</div>
|
||||
<div className="text-xs text-gray-500">{type.count} activos</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar establecimientos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Establishments Grid */}
|
||||
<div className="grid mobile-grid md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{filteredEstablishments.map((establishment) => (
|
||||
<div key={establishment.id} className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{/* Image */}
|
||||
<div className="h-48 bg-gray-200 relative">
|
||||
<img
|
||||
src={establishment.imageUrl || '/api/placeholder/400/300'}
|
||||
alt={establishment.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
establishment.status === 'active' ? 'bg-green-100 text-green-800' :
|
||||
establishment.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{establishment.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{establishment.name}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-current" />
|
||||
<span className="text-sm text-gray-600">{establishment.rating || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||
{establishment.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span className="truncate">{establishment.location?.address}</span>
|
||||
</div>
|
||||
{establishment.owner && (
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{establishment.owner.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
title="Ver detalles"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="text-green-600 hover:text-green-900"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(establishment.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{establishment.status === 'pending' && (
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(establishment.id, 'active')}
|
||||
className="p-1 text-green-600 hover:text-green-900"
|
||||
title="Aprobar"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(establishment.id, 'suspended')}
|
||||
className="p-1 text-red-600 hover:text-red-900"
|
||||
title="Rechazar"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEstablishments.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Building className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay establecimientos</h3>
|
||||
<p className="text-gray-500">No se encontraron establecimientos que coincidan con los filtros aplicados.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesTab;
|
||||
37
src/components/admin/SupportTab.tsx
Normal file
37
src/components/admin/SupportTab.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Phone, MessageSquare, HeadphonesIcon } from 'lucide-react';
|
||||
|
||||
interface SupportTabProps {
|
||||
isAdmin: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
const SupportTab: React.FC<SupportTabProps> = ({ }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Centro de Soporte y Tickets</h2>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<HeadphonesIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Centro de Soporte y Tickets
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Esta sección está en desarrollo y se implementará según las especificaciones del informe.
|
||||
</p>
|
||||
<div className="text-sm text-gray-500">
|
||||
Funcionalidades pendientes:
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>• Sistema de tickets de soporte</li>
|
||||
<li>• Chat en vivo</li>
|
||||
<li>• Base de conocimientos</li>
|
||||
<li>• Métricas de soporte</li>
|
||||
<li>• Escalación automática</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportTab;
|
||||
408
src/components/admin/UsersTab.tsx
Normal file
408
src/components/admin/UsersTab.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
UserPlus,
|
||||
Shield,
|
||||
Users,
|
||||
Car,
|
||||
UtensilsCrossed,
|
||||
Hotel,
|
||||
MapPin,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface UsersTabProps {
|
||||
users: any[];
|
||||
isAdmin: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
loadUsers: (page?: number, limit?: number, role?: string) => void;
|
||||
createUser?: (userData: any) => Promise<{ success: boolean; error?: string }>;
|
||||
updateUser?: (id: string, userData: any) => Promise<{ success: boolean; error?: string }>;
|
||||
deleteUser?: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
const UsersTab: React.FC<UsersTabProps> = ({
|
||||
users,
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
loadUsers
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [mockUsers, setMockUsers] = useState([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Super Admin',
|
||||
email: 'superadmin@karibeo.com',
|
||||
role: 'super_admin',
|
||||
status: 'active',
|
||||
verified: true,
|
||||
lastLogin: '2024-01-15 10:30:00',
|
||||
avatar: '/api/placeholder/40/40',
|
||||
createdAt: '2023-01-01'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Admin User',
|
||||
email: 'admin@karibeo.com',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
verified: true,
|
||||
lastLogin: '2024-01-14 15:45:00',
|
||||
avatar: '/api/placeholder/40/40',
|
||||
createdAt: '2023-02-01'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Juan Pérez',
|
||||
email: 'juan.perez@karibeo.com',
|
||||
role: 'tourist',
|
||||
status: 'active',
|
||||
verified: true,
|
||||
lastLogin: '2024-01-13 09:15:00',
|
||||
avatar: '/api/placeholder/40/40',
|
||||
createdAt: '2023-03-15'
|
||||
}
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(1, 50, roleFilter === 'all' ? undefined : roleFilter);
|
||||
}, [roleFilter]);
|
||||
|
||||
const handleCreateUser = async (formData: any) => {
|
||||
const newUser = {
|
||||
id: (mockUsers.length + 1).toString(),
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
status: 'active',
|
||||
verified: false,
|
||||
lastLogin: 'Nunca',
|
||||
avatar: '/api/placeholder/40/40',
|
||||
createdAt: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
setMockUsers([...mockUsers, newUser]);
|
||||
setShowCreateModal(false);
|
||||
toast.success('Usuario creado exitosamente');
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
setMockUsers(mockUsers.filter(user => user.id !== userId));
|
||||
toast.success('Usuario eliminado exitosamente');
|
||||
};
|
||||
|
||||
const getRoleName = (role: any): string => (typeof role === 'string' ? role : role?.name || '');
|
||||
|
||||
const filteredUsers = (users.length > 0 ? users : mockUsers).filter(user => {
|
||||
const matchesSearch = user.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const normalizedRole = getRoleName(user.role);
|
||||
const matchesRole = roleFilter === 'all' || normalizedRole === roleFilter;
|
||||
const matchesStatus = statusFilter === 'all' || user.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesRole && matchesStatus;
|
||||
});
|
||||
|
||||
const getRoleIcon = (role: string) => {
|
||||
const iconMap = {
|
||||
'super_admin': Shield,
|
||||
'admin': Shield,
|
||||
'tourist': Users,
|
||||
'guide': MapPin,
|
||||
'hotel': Hotel,
|
||||
'taxi': Car,
|
||||
'restaurant': UtensilsCrossed
|
||||
};
|
||||
const Icon = iconMap[role as keyof typeof iconMap] || Users;
|
||||
return <Icon className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
const colorMap = {
|
||||
'super_admin': 'bg-purple-100 text-purple-800',
|
||||
'admin': 'bg-blue-100 text-blue-800',
|
||||
'tourist': 'bg-green-100 text-green-800',
|
||||
'guide': 'bg-yellow-100 text-yellow-800',
|
||||
'hotel': 'bg-indigo-100 text-indigo-800',
|
||||
'taxi': 'bg-red-100 text-red-800',
|
||||
'restaurant': 'bg-orange-100 text-orange-800'
|
||||
};
|
||||
return colorMap[role as keyof typeof colorMap] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string, verified: boolean) => {
|
||||
if (status === 'active' && verified) return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
if (status === 'active' && !verified) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Gestión de Usuarios</h2>
|
||||
<p className="text-gray-600">Administra usuarios, roles y permisos del sistema</p>
|
||||
</div>
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Nuevo Usuario
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crear Nuevo Usuario</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UserForm
|
||||
onSubmit={handleCreateUser}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<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 o email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Filtrar por rol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los roles</SelectItem>
|
||||
<SelectItem value="super_admin">Super Admin</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="tourist">Turista</SelectItem>
|
||||
<SelectItem value="guide">Guía</SelectItem>
|
||||
<SelectItem value="hotel">Hotel</SelectItem>
|
||||
<SelectItem value="taxi">Taxi</SelectItem>
|
||||
<SelectItem value="restaurant">Restaurante</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Filtrar por estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los estados</SelectItem>
|
||||
<SelectItem value="active">Activo</SelectItem>
|
||||
<SelectItem value="pending">Pendiente</SelectItem>
|
||||
<SelectItem value="suspended">Suspendido</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de Usuarios ({filteredUsers.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Usuario</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Rol</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Estado</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Última Conexión</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Registrado</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-700">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>
|
||||
{user.name?.split(' ').map((n: string) => n[0]).join('').toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.name}</div>
|
||||
<div className="text-sm text-gray-600">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge className={getRoleBadgeColor(getRoleName(user.role))}>
|
||||
{getRoleIcon(getRoleName(user.role))}
|
||||
<span className="ml-1 capitalize">{getRoleName(user.role).replace('_', ' ')}</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(user.status, user.verified)}
|
||||
<span className="capitalize">{user.status}</span>
|
||||
{!user.verified && <span className="text-xs text-yellow-600">(No verificado)</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{user.lastLogin === 'Nunca' ? 'Nunca' : new Date(user.lastLogin).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{isSuperAdmin && getRoleName(user.role) !== 'super_admin' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserForm = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
user = null,
|
||||
isSuperAdmin = false
|
||||
}: {
|
||||
onSubmit: (data: any) => void;
|
||||
onCancel: () => void;
|
||||
user?: any;
|
||||
isSuperAdmin: boolean;
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: user?.name || '',
|
||||
email: user?.email || '',
|
||||
role: (typeof user?.role === 'string' ? user?.role : user?.role?.name) || 'tourist',
|
||||
phone: user?.profile?.phone || '',
|
||||
address: user?.profile?.address || '',
|
||||
status: user?.status || 'active'
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre Completo</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="role">Rol</Label>
|
||||
<Select value={formData.role} onValueChange={(value) => setFormData({ ...formData, role: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tourist">Turista</SelectItem>
|
||||
<SelectItem value="guide">Guía Turístico</SelectItem>
|
||||
<SelectItem value="hotel">Hotel</SelectItem>
|
||||
<SelectItem value="taxi">Taxista</SelectItem>
|
||||
<SelectItem value="restaurant">Restaurante</SelectItem>
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<SelectItem value="admin">Administrador</SelectItem>
|
||||
<SelectItem value="super_admin">Super Administrador</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone">Teléfono</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="address">Dirección</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{user ? 'Actualizar' : 'Crear'} Usuario
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersTab;
|
||||
Reference in New Issue
Block a user