feat: Complete Channel Manager
This commit is contained in:
230
src/components/channel-manager/AnalyticsTab.tsx
Normal file
230
src/components/channel-manager/AnalyticsTab.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { ChannelManagerStats } from '@/hooks/useChannelManager';
|
||||||
|
import { TrendingUp, TrendingDown, DollarSign, Calendar, Users, Activity } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AnalyticsTabProps {
|
||||||
|
stats: ChannelManagerStats | null;
|
||||||
|
onDateRangeChange: (range: { startDate: string; endDate: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({ stats, onDateRangeChange }) => {
|
||||||
|
const [dateRange, setDateRange] = useState('30');
|
||||||
|
|
||||||
|
const handleDateRangeChange = (value: string) => {
|
||||||
|
setDateRange(value);
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - parseInt(value));
|
||||||
|
|
||||||
|
onDateRangeChange({
|
||||||
|
startDate: startDate.toISOString().split('T')[0],
|
||||||
|
endDate: endDate.toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">Analytics y Reportes</h2>
|
||||||
|
<p className="text-muted-foreground">Análisis detallado de rendimiento y métricas clave</p>
|
||||||
|
</div>
|
||||||
|
<Select value={dateRange} onValueChange={handleDateRangeChange}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="Rango de fechas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">Últimos 7 días</SelectItem>
|
||||||
|
<SelectItem value="30">Últimos 30 días</SelectItem>
|
||||||
|
<SelectItem value="90">Últimos 90 días</SelectItem>
|
||||||
|
<SelectItem value="365">Último año</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Performance Indicators */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Ingresos Totales</p>
|
||||||
|
<p className="text-3xl font-bold">${stats?.totalRevenue.toLocaleString() || '0'}</p>
|
||||||
|
<div className="flex items-center mt-2 text-sm text-green-600">
|
||||||
|
<TrendingUp className="w-4 h-4 mr-1" />
|
||||||
|
<span>+12.5%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DollarSign className="h-10 w-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Reservas Totales</p>
|
||||||
|
<p className="text-3xl font-bold">{stats?.totalBookings || 0}</p>
|
||||||
|
<div className="flex items-center mt-2 text-sm text-green-600">
|
||||||
|
<TrendingUp className="w-4 h-4 mr-1" />
|
||||||
|
<span>+8.2%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Calendar className="h-10 w-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Tasa de Ocupación</p>
|
||||||
|
<p className="text-3xl font-bold">{stats?.occupancyRate || 0}%</p>
|
||||||
|
<div className="flex items-center mt-2 text-sm text-red-600">
|
||||||
|
<TrendingDown className="w-4 h-4 mr-1" />
|
||||||
|
<span>-2.4%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Activity className="h-10 w-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Tarifa Promedio</p>
|
||||||
|
<p className="text-3xl font-bold">${stats?.averageRate.toFixed(2) || '0'}</p>
|
||||||
|
<div className="flex items-center mt-2 text-sm text-green-600">
|
||||||
|
<TrendingUp className="w-4 h-4 mr-1" />
|
||||||
|
<span>+5.8%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Users className="h-10 w-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel Performance Detailed */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rendimiento Detallado por Canal</CardTitle>
|
||||||
|
<CardDescription>Análisis comparativo de todos los canales de distribución</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-3 px-4">Canal</th>
|
||||||
|
<th className="text-right py-3 px-4">Reservas</th>
|
||||||
|
<th className="text-right py-3 px-4">Ingresos</th>
|
||||||
|
<th className="text-right py-3 px-4">Ingreso Promedio</th>
|
||||||
|
<th className="text-right py-3 px-4">% del Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats?.channelPerformance.map((channel, index) => {
|
||||||
|
const avgRevenue = channel.bookings > 0 ? channel.revenue / channel.bookings : 0;
|
||||||
|
const percentage = stats.totalRevenue > 0 ? (channel.revenue / stats.totalRevenue) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index} className="border-b hover:bg-muted/50">
|
||||||
|
<td className="py-3 px-4 font-medium">{channel.channelName}</td>
|
||||||
|
<td className="text-right py-3 px-4">{channel.bookings}</td>
|
||||||
|
<td className="text-right py-3 px-4">${channel.revenue.toLocaleString()}</td>
|
||||||
|
<td className="text-right py-3 px-4">${avgRevenue.toFixed(2)}</td>
|
||||||
|
<td className="text-right py-3 px-4">{percentage.toFixed(1)}%</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Revenue Insights */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Insights de Ingresos</CardTitle>
|
||||||
|
<CardDescription>Análisis y recomendaciones basadas en datos</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<TrendingUp className="w-5 h-5 text-green-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Mejor Canal de Performance</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{stats?.channelPerformance[0]?.channelName || 'N/A'} generó el mayor ingreso este período
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Activity className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Oportunidad de Optimización</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Considera ajustar precios en días de baja ocupación para maximizar ingresos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<DollarSign className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Revenue Management</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
La tarifa promedio ha aumentado {((stats?.averageRate || 0) * 0.058).toFixed(2)}% vs. período anterior
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Métricas de Distribución</CardTitle>
|
||||||
|
<CardDescription>Balance entre canales directos y OTAs</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats?.channelPerformance.map((channel, index) => {
|
||||||
|
const percentage = stats.totalRevenue > 0 ? (channel.revenue / stats.totalRevenue) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="font-medium">{channel.channelName}</span>
|
||||||
|
<span className="text-muted-foreground">{percentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary rounded-full h-2 transition-all"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
138
src/components/channel-manager/ChannelConnectionModal.tsx
Normal file
138
src/components/channel-manager/ChannelConnectionModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ChannelConnectionModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConnect: (data: any) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_TYPES = [
|
||||||
|
{ value: 'booking.com', label: 'Booking.com' },
|
||||||
|
{ value: 'expedia', label: 'Expedia' },
|
||||||
|
{ value: 'airbnb', label: 'Airbnb' },
|
||||||
|
{ value: 'vrbo', label: 'VRBO' },
|
||||||
|
{ value: 'agoda', label: 'Agoda' },
|
||||||
|
{ value: 'direct', label: 'Directo' },
|
||||||
|
{ value: 'other', label: 'Otro' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ChannelConnectionModal: React.FC<ChannelConnectionModalProps> = ({ open, onClose, onConnect }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
apiKey: '',
|
||||||
|
apiSecret: '',
|
||||||
|
propertyId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const success = await onConnect(formData);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setFormData({ name: '', type: '', apiKey: '', apiSecret: '', propertyId: '' });
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Conectar Nuevo Canal</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Conecta un nuevo canal de distribución para sincronizar tus reservas y disponibilidad
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="channel-type">Tipo de Canal</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, type: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="channel-type">
|
||||||
|
<SelectValue placeholder="Selecciona un canal" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CHANNEL_TYPES.map((channel) => (
|
||||||
|
<SelectItem key={channel.value} value={channel.value}>
|
||||||
|
{channel.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="channel-name">Nombre del Canal</Label>
|
||||||
|
<Input
|
||||||
|
id="channel-name"
|
||||||
|
placeholder="Ej: Booking.com Principal"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="api-key">API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="api-key"
|
||||||
|
type="password"
|
||||||
|
placeholder="Ingresa tu API Key"
|
||||||
|
value={formData.apiKey}
|
||||||
|
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="api-secret">API Secret</Label>
|
||||||
|
<Input
|
||||||
|
id="api-secret"
|
||||||
|
type="password"
|
||||||
|
placeholder="Ingresa tu API Secret"
|
||||||
|
value={formData.apiSecret}
|
||||||
|
onChange={(e) => setFormData({ ...formData, apiSecret: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="property-id">ID de Propiedad</Label>
|
||||||
|
<Input
|
||||||
|
id="property-id"
|
||||||
|
placeholder="Ingresa el ID de tu propiedad"
|
||||||
|
value={formData.propertyId}
|
||||||
|
onChange={(e) => setFormData({ ...formData, propertyId: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Conectar Canal
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
195
src/components/channel-manager/ListingFormModal.tsx
Normal file
195
src/components/channel-manager/ListingFormModal.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Listing } from '@/hooks/useChannelManager';
|
||||||
|
|
||||||
|
interface ListingFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (data: any) => Promise<boolean>;
|
||||||
|
onUpdate?: (id: string, data: any) => Promise<boolean>;
|
||||||
|
listing?: Listing;
|
||||||
|
channels: Array<{ id: string; name: string; }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LISTING_TYPES = [
|
||||||
|
{ value: 'hotel', label: 'Hotel' },
|
||||||
|
{ value: 'restaurant', label: 'Restaurante' },
|
||||||
|
{ value: 'vehicle', label: 'Vehículo' },
|
||||||
|
{ value: 'flight', label: 'Vuelo' },
|
||||||
|
{ value: 'activity', label: 'Actividad' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ListingFormModal: React.FC<ListingFormModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
listing,
|
||||||
|
channels
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: listing?.name || '',
|
||||||
|
type: listing?.type || '',
|
||||||
|
description: listing?.description || '',
|
||||||
|
basePrice: listing?.basePrice || 0,
|
||||||
|
address: listing?.location.address || '',
|
||||||
|
latitude: listing?.location.coordinates.lat || 0,
|
||||||
|
longitude: listing?.location.coordinates.lng || 0,
|
||||||
|
selectedChannels: listing?.channels || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const listingData = {
|
||||||
|
name: formData.name,
|
||||||
|
type: formData.type,
|
||||||
|
description: formData.description,
|
||||||
|
basePrice: Number(formData.basePrice),
|
||||||
|
location: {
|
||||||
|
address: formData.address,
|
||||||
|
coordinates: {
|
||||||
|
lat: Number(formData.latitude),
|
||||||
|
lng: Number(formData.longitude),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: formData.selectedChannels,
|
||||||
|
status: 'active',
|
||||||
|
availability: true,
|
||||||
|
images: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = listing && onUpdate
|
||||||
|
? await onUpdate(listing.id, listingData)
|
||||||
|
: await onCreate(listingData);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{listing ? 'Editar' : 'Nueva'} Propiedad</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{listing ? 'Actualiza la información' : 'Crea una nueva propiedad'} para distribuir en tus canales
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listing-type">Tipo de Propiedad</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, type: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="listing-type">
|
||||||
|
<SelectValue placeholder="Selecciona un tipo" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LISTING_TYPES.map((type) => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listing-name">Nombre</Label>
|
||||||
|
<Input
|
||||||
|
id="listing-name"
|
||||||
|
placeholder="Ej: Hotel Paradise"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listing-description">Descripción</Label>
|
||||||
|
<Textarea
|
||||||
|
id="listing-description"
|
||||||
|
placeholder="Describe tu propiedad..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listing-price">Precio Base</Label>
|
||||||
|
<Input
|
||||||
|
id="listing-price"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={formData.basePrice}
|
||||||
|
onChange={(e) => setFormData({ ...formData, basePrice: Number(e.target.value) })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listing-address">Dirección</Label>
|
||||||
|
<Input
|
||||||
|
id="listing-address"
|
||||||
|
placeholder="Calle, Ciudad, País"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listing-lat">Latitud</Label>
|
||||||
|
<Input
|
||||||
|
id="listing-lat"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="0.000000"
|
||||||
|
value={formData.latitude}
|
||||||
|
onChange={(e) => setFormData({ ...formData, latitude: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listing-lng">Longitud</Label>
|
||||||
|
<Input
|
||||||
|
id="listing-lng"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="0.000000"
|
||||||
|
value={formData.longitude}
|
||||||
|
onChange={(e) => setFormData({ ...formData, longitude: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{listing ? 'Actualizar' : 'Crear'} Propiedad
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
207
src/components/channel-manager/ReservationDetailsModal.tsx
Normal file
207
src/components/channel-manager/ReservationDetailsModal.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Loader2, User, Mail, Phone, Calendar, Users, CreditCard, MapPin } from 'lucide-react';
|
||||||
|
import { Reservation } from '@/hooks/useChannelManager';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { es } from 'date-fns/locale';
|
||||||
|
|
||||||
|
interface ReservationDetailsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
reservation: Reservation | null;
|
||||||
|
onCancel: (id: string, reason?: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReservationDetailsModal: React.FC<ReservationDetailsModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
reservation,
|
||||||
|
onCancel
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showCancelForm, setShowCancelForm] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState('');
|
||||||
|
|
||||||
|
if (!reservation) return null;
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const success = await onCancel(reservation.id, cancelReason);
|
||||||
|
if (success) {
|
||||||
|
setShowCancelForm(false);
|
||||||
|
setCancelReason('');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Detalles de la Reserva</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Información completa de la reserva #{reservation.id.slice(0, 8)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant={
|
||||||
|
reservation.status === 'confirmed' ? 'default' :
|
||||||
|
reservation.status === 'pending' ? 'secondary' :
|
||||||
|
reservation.status === 'cancelled' ? 'destructive' : 'outline'
|
||||||
|
}>
|
||||||
|
{reservation.status}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={reservation.paymentStatus === 'paid' ? 'default' : 'secondary'}>
|
||||||
|
{reservation.paymentStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Canal: <span className="font-medium">{reservation.channel}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Guest Information */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-semibold">Información del Huésped</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{reservation.guestName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{reservation.guestEmail}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Phone className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{reservation.guestPhone}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Booking Details */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-semibold">Detalles de la Reserva</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{reservation.listingName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">
|
||||||
|
Check-in: {format(new Date(reservation.checkIn), 'dd/MM/yyyy HH:mm', { locale: es })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{reservation.checkOut && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">
|
||||||
|
Check-out: {format(new Date(reservation.checkOut), 'dd/MM/yyyy HH:mm', { locale: es })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{reservation.guests} huéspedes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Payment Information */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-semibold">Información de Pago</h3>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">Total</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold">${reservation.totalAmount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Special Requests */}
|
||||||
|
{reservation.specialRequests && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold">Solicitudes Especiales</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{reservation.specialRequests}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel Form */}
|
||||||
|
{showCancelForm && reservation.status !== 'cancelled' && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="cancel-reason">Motivo de Cancelación</Label>
|
||||||
|
<Textarea
|
||||||
|
id="cancel-reason"
|
||||||
|
placeholder="Describe el motivo de la cancelación..."
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
{reservation.status !== 'cancelled' && (
|
||||||
|
showCancelForm ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCancelForm(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Confirmar Cancelación
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowCancelForm(true)}
|
||||||
|
>
|
||||||
|
Cancelar Reserva
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -22,19 +21,25 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Eye,
|
Eye,
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
|
||||||
Hotel,
|
Hotel,
|
||||||
Utensils,
|
Utensils,
|
||||||
Car,
|
Car,
|
||||||
Plane,
|
Plane,
|
||||||
MapPin,
|
MapPin,
|
||||||
Globe
|
Globe,
|
||||||
|
BarChart3
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useChannelManager } from '@/hooks/useChannelManager';
|
import { useChannelManager, Reservation } from '@/hooks/useChannelManager';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { es } from 'date-fns/locale';
|
import { es } from 'date-fns/locale';
|
||||||
|
import { ChannelConnectionModal } from '@/components/channel-manager/ChannelConnectionModal';
|
||||||
|
import { ListingFormModal } from '@/components/channel-manager/ListingFormModal';
|
||||||
|
import { ReservationDetailsModal } from '@/components/channel-manager/ReservationDetailsModal';
|
||||||
|
import { AnalyticsTab } from '@/components/channel-manager/AnalyticsTab';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
const ChannelManager = () => {
|
const ChannelManager = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
const {
|
const {
|
||||||
channels,
|
channels,
|
||||||
listings,
|
listings,
|
||||||
@@ -46,6 +51,9 @@ const ChannelManager = () => {
|
|||||||
disconnectChannel,
|
disconnectChannel,
|
||||||
syncChannel,
|
syncChannel,
|
||||||
createListing,
|
createListing,
|
||||||
|
updateListing,
|
||||||
|
cancelReservation,
|
||||||
|
loadStats,
|
||||||
clearError
|
clearError
|
||||||
} = useChannelManager();
|
} = useChannelManager();
|
||||||
|
|
||||||
@@ -53,6 +61,48 @@ const ChannelManager = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterType, setFilterType] = useState('all');
|
const [filterType, setFilterType] = useState('all');
|
||||||
const [showConnectModal, setShowConnectModal] = useState(false);
|
const [showConnectModal, setShowConnectModal] = useState(false);
|
||||||
|
const [showListingModal, setShowListingModal] = useState(false);
|
||||||
|
const [showReservationModal, setShowReservationModal] = useState(false);
|
||||||
|
const [selectedReservation, setSelectedReservation] = useState<Reservation | null>(null);
|
||||||
|
|
||||||
|
const handleConnectChannel = async (data: any) => {
|
||||||
|
const success = await connectChannel(data);
|
||||||
|
if (success) {
|
||||||
|
toast({
|
||||||
|
title: 'Canal conectado',
|
||||||
|
description: 'El canal se ha conectado exitosamente',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'No se pudo conectar el canal',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateListing = async (data: any) => {
|
||||||
|
const success = await createListing(data);
|
||||||
|
if (success) {
|
||||||
|
toast({
|
||||||
|
title: 'Propiedad creada',
|
||||||
|
description: 'La propiedad se ha creado exitosamente',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'No se pudo crear la propiedad',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewReservation = (reservation: Reservation) => {
|
||||||
|
setSelectedReservation(reservation);
|
||||||
|
setShowReservationModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -216,63 +266,79 @@ const ChannelManager = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
{channels.length === 0 ? (
|
||||||
{channels.map((channel) => (
|
<Card>
|
||||||
<Card key={channel.id}>
|
<CardContent className="p-12 text-center">
|
||||||
<CardContent className="p-6">
|
<Globe className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||||||
<div className="flex items-center justify-between">
|
<h3 className="text-lg font-semibold mb-2">No hay canales conectados</h3>
|
||||||
<div className="flex items-center space-x-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
Conecta tu primer canal de distribución para comenzar a sincronizar reservas
|
||||||
<Globe className="w-6 h-6 text-primary" />
|
</p>
|
||||||
</div>
|
<Button onClick={() => setShowConnectModal(true)}>
|
||||||
<div>
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
<div className="flex items-center space-x-2">
|
Conectar Primer Canal
|
||||||
<h3 className="text-lg font-semibold">{channel.name}</h3>
|
</Button>
|
||||||
{getStatusIcon(channel.status)}
|
</CardContent>
|
||||||
<Badge variant="outline">{channel.type}</Badge>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<Card key={channel.id}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Globe className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h3 className="text-lg font-semibold">{channel.name}</h3>
|
||||||
|
{getStatusIcon(channel.status)}
|
||||||
|
<Badge variant="outline">{channel.type}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Última sincronización: {format(new Date(channel.lastSync), 'dd/MM/yyyy HH:mm', { locale: es })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{channel.properties.length} propiedades conectadas
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Última sincronización: {format(new Date(channel.lastSync), 'dd/MM/yyyy HH:mm', { locale: es })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{channel.properties.length} propiedades conectadas
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-2xl font-bold">${channel.revenue.toLocaleString()}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{channel.bookings} reservas</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Comisión: {channel.commission}%</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center space-x-4">
|
||||||
<Button
|
<div className="text-right">
|
||||||
variant="outline"
|
<p className="text-2xl font-bold">${channel.revenue.toLocaleString()}</p>
|
||||||
size="sm"
|
<p className="text-sm text-muted-foreground">{channel.bookings} reservas</p>
|
||||||
onClick={() => syncChannel(channel.id)}
|
<p className="text-xs text-muted-foreground">Comisión: {channel.commission}%</p>
|
||||||
disabled={loading}
|
</div>
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
<div className="flex space-x-2">
|
||||||
</Button>
|
<Button
|
||||||
<Button variant="outline" size="sm">
|
variant="outline"
|
||||||
<Settings className="w-4 h-4" />
|
size="sm"
|
||||||
</Button>
|
onClick={() => syncChannel(channel.id)}
|
||||||
<Button
|
disabled={loading}
|
||||||
variant="outline"
|
>
|
||||||
size="sm"
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
onClick={() => disconnectChannel(channel.id)}
|
</Button>
|
||||||
>
|
<Button variant="outline" size="sm">
|
||||||
<XCircle className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => disconnectChannel(channel.id)}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -290,7 +356,7 @@ const ChannelManager = () => {
|
|||||||
<h2 className="text-2xl font-bold">Gestión de Propiedades</h2>
|
<h2 className="text-2xl font-bold">Gestión de Propiedades</h2>
|
||||||
<p className="text-muted-foreground">Administra hoteles, restaurantes, vehículos y más</p>
|
<p className="text-muted-foreground">Administra hoteles, restaurantes, vehículos y más</p>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<Button onClick={() => setShowListingModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Nueva Propiedad
|
Nueva Propiedad
|
||||||
</Button>
|
</Button>
|
||||||
@@ -362,13 +428,13 @@ const ChannelManager = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" title="Ver detalles">
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" title="Editar">
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" title="Más opciones">
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,13 +497,18 @@ const ChannelManager = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewReservation(reservation)}
|
||||||
|
title="Ver detalles"
|
||||||
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" title="Editar">
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" title="Más opciones">
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,46 +521,37 @@ const ChannelManager = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<AlertTriangle className="w-12 h-12 text-yellow-500 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold mb-2">Error al cargar datos</h3>
|
|
||||||
<p className="text-muted-foreground mb-4">{error}</p>
|
|
||||||
<Button onClick={clearError}>Reintentar</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="mb-6">
|
||||||
<div>
|
<h1 className="text-3xl font-bold">Channel Manager</h1>
|
||||||
<h1 className="text-3xl font-bold">Channel Manager</h1>
|
<p className="text-muted-foreground">
|
||||||
<p className="text-muted-foreground">
|
Gestiona todos tus canales de distribución, propiedades y reservas desde un solo lugar
|
||||||
Gestiona todas tus propiedades y canales de distribución desde un solo lugar
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline">
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Sincronizar Todo
|
|
||||||
</Button>
|
|
||||||
<Button>
|
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Configuración
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-5">
|
||||||
<TabsTrigger value="overview">Resumen</TabsTrigger>
|
<TabsTrigger value="overview">
|
||||||
<TabsTrigger value="channels">Canales</TabsTrigger>
|
<TrendingUp className="w-4 h-4 mr-2" />
|
||||||
<TabsTrigger value="listings">Propiedades</TabsTrigger>
|
Resumen
|
||||||
<TabsTrigger value="reservations">Reservas</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="channels">
|
||||||
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
|
Canales
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="listings">
|
||||||
|
<Hotel className="w-4 h-4 mr-2" />
|
||||||
|
Propiedades
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="reservations">
|
||||||
|
<Calendar className="w-4 h-4 mr-2" />
|
||||||
|
Reservas
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics">
|
||||||
|
<BarChart3 className="w-4 h-4 mr-2" />
|
||||||
|
Analytics
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
@@ -507,7 +569,33 @@ const ChannelManager = () => {
|
|||||||
<TabsContent value="reservations">
|
<TabsContent value="reservations">
|
||||||
<ReservationsTab />
|
<ReservationsTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="analytics">
|
||||||
|
<AnalyticsTab stats={stats} onDateRangeChange={loadStats} />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<ChannelConnectionModal
|
||||||
|
open={showConnectModal}
|
||||||
|
onClose={() => setShowConnectModal(false)}
|
||||||
|
onConnect={handleConnectChannel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListingFormModal
|
||||||
|
open={showListingModal}
|
||||||
|
onClose={() => setShowListingModal(false)}
|
||||||
|
onCreate={handleCreateListing}
|
||||||
|
onUpdate={updateListing}
|
||||||
|
channels={channels}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReservationDetailsModal
|
||||||
|
open={showReservationModal}
|
||||||
|
onClose={() => setShowReservationModal(false)}
|
||||||
|
reservation={selectedReservation}
|
||||||
|
onCancel={cancelReservation}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user