641 lines
21 KiB
TypeScript
641 lines
21 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import {
|
|
Calendar,
|
|
MapPin,
|
|
Users,
|
|
CreditCard,
|
|
Eye,
|
|
XCircle,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
Clock,
|
|
DoorOpen,
|
|
DoorClosed,
|
|
Loader2,
|
|
Search,
|
|
Filter,
|
|
} from 'lucide-react';
|
|
import { toast } from 'react-toastify';
|
|
import {
|
|
getMyBookings,
|
|
cancelBooking,
|
|
type Booking,
|
|
} from '../../services/api/bookingService';
|
|
import useAuthStore from '../../store/useAuthStore';
|
|
import Loading from '../../components/common/Loading';
|
|
import EmptyState from '../../components/common/EmptyState';
|
|
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
|
|
|
const MyBookingsPage: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const { isAuthenticated } = useAuthStore();
|
|
const { formatCurrency } = useFormatCurrency();
|
|
|
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
|
const [filteredBookings, setFilteredBookings] =
|
|
useState<Booking[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [cancellingId, setCancellingId] =
|
|
useState<number | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilter, setStatusFilter] =
|
|
useState<string>('all');
|
|
|
|
// Redirect if not authenticated
|
|
useEffect(() => {
|
|
if (!isAuthenticated) {
|
|
toast.error('Please login to view your bookings');
|
|
navigate('/login', {
|
|
state: { from: '/bookings' }
|
|
});
|
|
}
|
|
}, [isAuthenticated, navigate]);
|
|
|
|
// Fetch bookings
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
fetchBookings();
|
|
}
|
|
}, [isAuthenticated]);
|
|
|
|
// Filter bookings
|
|
useEffect(() => {
|
|
let filtered = [...bookings];
|
|
|
|
// Filter by status
|
|
if (statusFilter !== 'all') {
|
|
filtered = filtered.filter(
|
|
(b) => b.status === statusFilter
|
|
);
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
filtered = filtered.filter(
|
|
(b) =>
|
|
b.booking_number.toLowerCase().includes(query) ||
|
|
b.room?.room_type?.name
|
|
.toLowerCase()
|
|
.includes(query) ||
|
|
b.room?.room_number
|
|
.toString()
|
|
.includes(query)
|
|
);
|
|
}
|
|
|
|
setFilteredBookings(filtered);
|
|
}, [bookings, statusFilter, searchQuery]);
|
|
|
|
const fetchBookings = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await getMyBookings();
|
|
|
|
if (
|
|
response.success &&
|
|
response.data?.bookings
|
|
) {
|
|
setBookings(response.data.bookings);
|
|
} else {
|
|
throw new Error(
|
|
'Unable to load bookings list'
|
|
);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error fetching bookings:', err);
|
|
const message =
|
|
err.response?.data?.message ||
|
|
'Unable to load bookings list';
|
|
setError(message);
|
|
toast.error(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelBooking = async (
|
|
bookingId: number,
|
|
bookingNumber: string
|
|
) => {
|
|
const confirmed = window.confirm(
|
|
`Are you sure you want to cancel booking ${bookingNumber}?\n\n` +
|
|
`⚠️ Note:\n` +
|
|
`- You will be charged 20% of the order value\n` +
|
|
`- The remaining 80% will be refunded\n` +
|
|
`- Room status will be updated to "available"`
|
|
);
|
|
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
setCancellingId(bookingId);
|
|
|
|
const response = await cancelBooking(bookingId);
|
|
|
|
if (response.success) {
|
|
toast.success(
|
|
`✅ Successfully cancelled booking ${bookingNumber}!`
|
|
);
|
|
|
|
// Update local state
|
|
setBookings((prev) =>
|
|
prev.map((b) =>
|
|
b.id === bookingId
|
|
? { ...b, status: 'cancelled' }
|
|
: b
|
|
)
|
|
);
|
|
} else {
|
|
throw new Error(
|
|
response.message ||
|
|
'Unable to cancel booking'
|
|
);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error cancelling booking:', err);
|
|
const message =
|
|
err.response?.data?.message ||
|
|
'Unable to cancel booking. Please try again.';
|
|
toast.error(message);
|
|
} finally {
|
|
setCancellingId(null);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
const formatPrice = (price: number) => formatCurrency(price);
|
|
|
|
const getStatusConfig = (status: string) => {
|
|
switch (status) {
|
|
case 'pending':
|
|
return {
|
|
icon: Clock,
|
|
color: 'bg-yellow-100 text-yellow-800',
|
|
text: 'Pending confirmation',
|
|
};
|
|
case 'confirmed':
|
|
return {
|
|
icon: CheckCircle,
|
|
color: 'bg-green-100 text-green-800',
|
|
text: 'Confirmed',
|
|
};
|
|
case 'cancelled':
|
|
return {
|
|
icon: XCircle,
|
|
color: 'bg-red-100 text-red-800',
|
|
text: 'Cancelled',
|
|
};
|
|
case 'checked_in':
|
|
return {
|
|
icon: DoorOpen,
|
|
color: 'bg-blue-100 text-blue-800',
|
|
text: 'Checked in',
|
|
};
|
|
case 'checked_out':
|
|
return {
|
|
icon: DoorClosed,
|
|
color: 'bg-gray-100 text-gray-800',
|
|
text: 'Checked out',
|
|
};
|
|
default:
|
|
return {
|
|
icon: AlertCircle,
|
|
color: 'bg-gray-100 text-gray-800',
|
|
text: status,
|
|
};
|
|
}
|
|
};
|
|
|
|
const canCancelBooking = (booking: Booking) => {
|
|
// Only allow cancellation of pending bookings
|
|
return booking.status === 'pending';
|
|
};
|
|
|
|
if (loading) {
|
|
return <Loading fullScreen text="Loading..." />;
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 py-8">
|
|
<div className="max-w-7xl mx-auto px-4">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900
|
|
mb-2"
|
|
>
|
|
My Bookings
|
|
</h1>
|
|
<p className="text-gray-600">
|
|
Manage and track your bookings
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-lg shadow-md
|
|
p-4 mb-6"
|
|
>
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|
{/* Search */}
|
|
<div className="flex-1">
|
|
<div className="relative">
|
|
<Search
|
|
className="absolute left-3 top-1/2
|
|
-translate-y-1/2 w-5 h-5
|
|
text-gray-400"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by booking number, room name..."
|
|
value={searchQuery}
|
|
onChange={(e) =>
|
|
setSearchQuery(e.target.value)
|
|
}
|
|
className="w-full pl-10 pr-4 py-2 border
|
|
border-gray-300 rounded-lg
|
|
focus:ring-2 focus:ring-indigo-500
|
|
focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div className="md:w-64">
|
|
<div className="relative">
|
|
<Filter
|
|
className="absolute left-3 top-1/2
|
|
-translate-y-1/2 w-5 h-5
|
|
text-gray-400"
|
|
/>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) =>
|
|
setStatusFilter(e.target.value)
|
|
}
|
|
className="w-full pl-10 pr-4 py-2 border
|
|
border-gray-300 rounded-lg
|
|
focus:ring-2 focus:ring-indigo-500
|
|
focus:border-indigo-500
|
|
appearance-none bg-white"
|
|
>
|
|
<option value="all">All statuses</option>
|
|
<option value="pending">Pending confirmation</option>
|
|
<option value="confirmed">Confirmed</option>
|
|
<option value="checked_in">
|
|
Checked in
|
|
</option>
|
|
<option value="checked_out">
|
|
Checked out
|
|
</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results count */}
|
|
<div className="mt-3 text-sm text-gray-600">
|
|
Showing {filteredBookings.length} /
|
|
{bookings.length} bookings
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error State */}
|
|
{error && (
|
|
<div
|
|
className="bg-red-50 border border-red-200
|
|
rounded-lg p-6 mb-6 flex items-start gap-3"
|
|
>
|
|
<AlertCircle
|
|
className="w-6 h-6 text-red-500
|
|
flex-shrink-0 mt-0.5"
|
|
/>
|
|
<div>
|
|
<p className="text-red-700 font-medium">
|
|
{error}
|
|
</p>
|
|
<button
|
|
onClick={fetchBookings}
|
|
className="mt-2 text-sm text-red-600
|
|
hover:text-red-800 underline"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bookings List */}
|
|
{filteredBookings.length === 0 ? (
|
|
<EmptyState
|
|
icon={Calendar}
|
|
title={
|
|
searchQuery || statusFilter !== 'all'
|
|
? 'No bookings found'
|
|
: 'No bookings yet'
|
|
}
|
|
description={
|
|
searchQuery || statusFilter !== 'all'
|
|
? 'Try changing filters or search keywords'
|
|
: 'Start booking to enjoy your vacation'
|
|
}
|
|
>
|
|
{!searchQuery && statusFilter === 'all' ? (
|
|
<Link
|
|
to="/rooms"
|
|
className="inline-flex items-center
|
|
gap-2 px-6 py-3 bg-indigo-600
|
|
text-white rounded-lg
|
|
hover:bg-indigo-700
|
|
transition-colors font-semibold"
|
|
>
|
|
View room list
|
|
</Link>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
setStatusFilter('all');
|
|
}}
|
|
className="px-6 py-3 bg-gray-600
|
|
text-white rounded-lg
|
|
hover:bg-gray-700 transition-colors
|
|
font-semibold"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
</EmptyState>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{filteredBookings.map((booking) => {
|
|
const statusConfig = getStatusConfig(
|
|
booking.status
|
|
);
|
|
const StatusIcon = statusConfig.icon;
|
|
const room = booking.room;
|
|
const roomType = room?.room_type;
|
|
|
|
return (
|
|
<div
|
|
key={booking.id}
|
|
className="bg-white rounded-lg shadow-md
|
|
hover:shadow-lg transition-shadow
|
|
overflow-hidden"
|
|
>
|
|
<div className="p-6">
|
|
<div className="flex flex-col
|
|
lg:flex-row gap-6"
|
|
>
|
|
{/* Room Image */}
|
|
{((room?.images && room.images.length > 0)
|
|
? room.images[0]
|
|
: roomType?.images?.[0]) && (
|
|
<div className="lg:w-48 flex-shrink-0">
|
|
<img
|
|
src={(room?.images && room.images.length > 0)
|
|
? room.images[0]
|
|
: (roomType?.images?.[0] || '')}
|
|
alt={roomType.name}
|
|
className="w-full h-48 lg:h-full
|
|
object-cover rounded-lg"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Booking Info */}
|
|
<div className="flex-1 min-w-0">
|
|
{/* Header */}
|
|
<div className="flex items-start
|
|
justify-between gap-4 mb-3"
|
|
>
|
|
<div>
|
|
<h3 className="text-xl font-bold
|
|
text-gray-900 mb-1"
|
|
>
|
|
{roomType?.name || 'N/A'}
|
|
</h3>
|
|
<p className="text-sm
|
|
text-gray-600"
|
|
>
|
|
<MapPin
|
|
className="w-4 h-4 inline
|
|
mr-1"
|
|
/>
|
|
Room {room?.room_number} -
|
|
Floor {room?.floor}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Status Badge */}
|
|
<div
|
|
className={`flex items-center
|
|
gap-2 px-3 py-1.5 rounded-full
|
|
text-sm font-medium
|
|
${statusConfig.color}`}
|
|
>
|
|
<StatusIcon
|
|
className="w-4 h-4"
|
|
/>
|
|
{statusConfig.text}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Details Grid */}
|
|
<div className="grid grid-cols-1
|
|
sm:grid-cols-2 gap-3 mb-4"
|
|
>
|
|
{/* Booking Number */}
|
|
<div>
|
|
<p className="text-xs text-gray-500
|
|
mb-1"
|
|
>
|
|
Booking number
|
|
</p>
|
|
<p className="font-medium
|
|
text-gray-900 font-mono"
|
|
>
|
|
{booking.booking_number}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Check-in */}
|
|
<div>
|
|
<p className="text-xs text-gray-500
|
|
mb-1"
|
|
>
|
|
<Calendar
|
|
className="w-3 h-3 inline
|
|
mr-1"
|
|
/>
|
|
Check-in date
|
|
</p>
|
|
<p className="font-medium
|
|
text-gray-900"
|
|
>
|
|
{formatDate(
|
|
booking.check_in_date
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Check-out */}
|
|
<div>
|
|
<p className="text-xs text-gray-500
|
|
mb-1"
|
|
>
|
|
<Calendar
|
|
className="w-3 h-3 inline
|
|
mr-1"
|
|
/>
|
|
Check-out date
|
|
</p>
|
|
<p className="font-medium
|
|
text-gray-900"
|
|
>
|
|
{formatDate(
|
|
booking.check_out_date
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Guest Count */}
|
|
<div>
|
|
<p className="text-xs text-gray-500
|
|
mb-1"
|
|
>
|
|
<Users
|
|
className="w-3 h-3 inline
|
|
mr-1"
|
|
/>
|
|
Guests
|
|
</p>
|
|
<p className="font-medium
|
|
text-gray-900"
|
|
>
|
|
{booking.guest_count} guest(s)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Payment Method */}
|
|
<div>
|
|
<p className="text-xs text-gray-500
|
|
mb-1"
|
|
>
|
|
<CreditCard
|
|
className="w-3 h-3 inline
|
|
mr-1"
|
|
/>
|
|
Payment
|
|
</p>
|
|
<p className="font-medium
|
|
text-gray-900"
|
|
>
|
|
{booking.payment_method === 'cash'
|
|
? 'On-site'
|
|
: 'Bank transfer'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Total Price */}
|
|
<div>
|
|
<p className="text-xs text-gray-500
|
|
mb-1"
|
|
>
|
|
Total price
|
|
</p>
|
|
<p className="font-bold
|
|
text-indigo-600 text-lg"
|
|
>
|
|
{formatPrice(booking.total_price)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-wrap gap-3
|
|
pt-4 border-t"
|
|
>
|
|
{/* View Details */}
|
|
<Link
|
|
to={`/bookings/${booking.id}`}
|
|
className="inline-flex items-center
|
|
gap-2 px-4 py-2
|
|
bg-indigo-600 text-white
|
|
rounded-lg hover:bg-indigo-700
|
|
transition-colors font-medium
|
|
text-sm"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
View details
|
|
</Link>
|
|
|
|
{/* Cancel Booking */}
|
|
{canCancelBooking(booking) && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleCancelBooking(
|
|
booking.id,
|
|
booking.booking_number
|
|
);
|
|
}}
|
|
disabled={
|
|
cancellingId === booking.id
|
|
}
|
|
className="inline-flex
|
|
items-center gap-2 px-4 py-2
|
|
bg-red-600 text-white
|
|
rounded-lg hover:bg-red-700
|
|
transition-colors font-medium
|
|
text-sm disabled:bg-gray-400
|
|
disabled:cursor-not-allowed cursor-pointer"
|
|
>
|
|
{cancellingId === booking.id ? (
|
|
<>
|
|
<Loader2
|
|
className="w-4 h-4
|
|
animate-spin"
|
|
/>
|
|
Cancelling...
|
|
</>
|
|
) : (
|
|
<>
|
|
<XCircle
|
|
className="w-4 h-4"
|
|
/>
|
|
Cancel booking
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MyBookingsPage;
|