feat: Complete Channel Manager

This commit is contained in:
gpt-engineer-app[bot]
2025-10-10 21:48:09 +00:00
parent 3d626de63f
commit 5e0260f764
5 changed files with 959 additions and 101 deletions

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

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

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

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