From 5e0260f764fd7fb64151f0a2f192206ad7edbcdf Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:48:09 +0000 Subject: [PATCH] feat: Complete Channel Manager --- .../channel-manager/AnalyticsTab.tsx | 230 ++++++++++++++ .../ChannelConnectionModal.tsx | 138 +++++++++ .../channel-manager/ListingFormModal.tsx | 195 ++++++++++++ .../ReservationDetailsModal.tsx | 207 +++++++++++++ src/pages/dashboard/ChannelManager.tsx | 290 ++++++++++++------ 5 files changed, 959 insertions(+), 101 deletions(-) create mode 100644 src/components/channel-manager/AnalyticsTab.tsx create mode 100644 src/components/channel-manager/ChannelConnectionModal.tsx create mode 100644 src/components/channel-manager/ListingFormModal.tsx create mode 100644 src/components/channel-manager/ReservationDetailsModal.tsx diff --git a/src/components/channel-manager/AnalyticsTab.tsx b/src/components/channel-manager/AnalyticsTab.tsx new file mode 100644 index 0000000..3ad6d6b --- /dev/null +++ b/src/components/channel-manager/AnalyticsTab.tsx @@ -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 = ({ 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 ( +
+
+
+

Analytics y Reportes

+

Análisis detallado de rendimiento y métricas clave

+
+ +
+ + {/* Key Performance Indicators */} +
+ + +
+
+

Ingresos Totales

+

${stats?.totalRevenue.toLocaleString() || '0'}

+
+ + +12.5% +
+
+ +
+
+
+ + + +
+
+

Reservas Totales

+

{stats?.totalBookings || 0}

+
+ + +8.2% +
+
+ +
+
+
+ + + +
+
+

Tasa de Ocupación

+

{stats?.occupancyRate || 0}%

+
+ + -2.4% +
+
+ +
+
+
+ + + +
+
+

Tarifa Promedio

+

${stats?.averageRate.toFixed(2) || '0'}

+
+ + +5.8% +
+
+ +
+
+
+
+ + {/* Channel Performance Detailed */} + + + Rendimiento Detallado por Canal + Análisis comparativo de todos los canales de distribución + + +
+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + +
CanalReservasIngresosIngreso Promedio% del Total
{channel.channelName}{channel.bookings}${channel.revenue.toLocaleString()}${avgRevenue.toFixed(2)}{percentage.toFixed(1)}%
+
+
+
+ + {/* Revenue Insights */} +
+ + + Insights de Ingresos + Análisis y recomendaciones basadas en datos + + +
+
+ +
+

Mejor Canal de Performance

+

+ {stats?.channelPerformance[0]?.channelName || 'N/A'} generó el mayor ingreso este período +

+
+
+
+ +
+
+ +
+

Oportunidad de Optimización

+

+ Considera ajustar precios en días de baja ocupación para maximizar ingresos +

+
+
+
+ +
+
+ +
+

Revenue Management

+

+ La tarifa promedio ha aumentado {((stats?.averageRate || 0) * 0.058).toFixed(2)}% vs. período anterior +

+
+
+
+
+
+ + + + Métricas de Distribución + Balance entre canales directos y OTAs + + +
+ {stats?.channelPerformance.map((channel, index) => { + const percentage = stats.totalRevenue > 0 ? (channel.revenue / stats.totalRevenue) * 100 : 0; + + return ( +
+
+ {channel.channelName} + {percentage.toFixed(1)}% +
+
+
+
+
+ ); + })} +
+ + +
+
+ ); +}; diff --git a/src/components/channel-manager/ChannelConnectionModal.tsx b/src/components/channel-manager/ChannelConnectionModal.tsx new file mode 100644 index 0000000..ef6ac78 --- /dev/null +++ b/src/components/channel-manager/ChannelConnectionModal.tsx @@ -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; +} + +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 = ({ 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 ( + + + + Conectar Nuevo Canal + + Conecta un nuevo canal de distribución para sincronizar tus reservas y disponibilidad + + + +
+
+ + +
+ +
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, apiKey: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, apiSecret: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, propertyId: e.target.value })} + required + /> +
+ + + + + +
+
+
+ ); +}; diff --git a/src/components/channel-manager/ListingFormModal.tsx b/src/components/channel-manager/ListingFormModal.tsx new file mode 100644 index 0000000..03e47e6 --- /dev/null +++ b/src/components/channel-manager/ListingFormModal.tsx @@ -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; + onUpdate?: (id: string, data: any) => Promise; + 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 = ({ + 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 ( + + + + {listing ? 'Editar' : 'Nueva'} Propiedad + + {listing ? 'Actualiza la información' : 'Crea una nueva propiedad'} para distribuir en tus canales + + + +
+
+ + +
+ +
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ +