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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user