update to python fastpi
This commit is contained in:
21
Frontend/src/pages/AdminLayout.tsx
Normal file
21
Frontend/src/pages/AdminLayout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { SidebarAdmin } from '../components/layout';
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Admin Sidebar */}
|
||||
<SidebarAdmin />
|
||||
|
||||
{/* Admin Content Area */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
410
Frontend/src/pages/HomePage.tsx
Normal file
410
Frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
BannerCarousel,
|
||||
BannerSkeleton,
|
||||
RoomCard,
|
||||
RoomCardSkeleton,
|
||||
SearchRoomForm,
|
||||
} from '../components/rooms';
|
||||
import {
|
||||
bannerService,
|
||||
roomService
|
||||
} from '../services/api';
|
||||
import type { Banner } from '../services/api/bannerService';
|
||||
import type { Room } from '../services/api/roomService';
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
|
||||
const [newestRooms, setNewestRooms] = useState<Room[]>([]);
|
||||
const [isLoadingBanners, setIsLoadingBanners] =
|
||||
useState(true);
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch banners
|
||||
useEffect(() => {
|
||||
const fetchBanners = async () => {
|
||||
try {
|
||||
setIsLoadingBanners(true);
|
||||
const response = await bannerService
|
||||
.getBannersByPosition('home');
|
||||
|
||||
// Handle both response formats
|
||||
if (
|
||||
response.success ||
|
||||
response.status === 'success'
|
||||
) {
|
||||
setBanners(response.data?.banners || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching banners:', err);
|
||||
// Don't show error for banners, just use fallback
|
||||
// Silently fail - banners are not critical for page functionality
|
||||
} finally {
|
||||
setIsLoadingBanners(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBanners();
|
||||
}, []);
|
||||
|
||||
// Fetch featured rooms
|
||||
useEffect(() => {
|
||||
const fetchFeaturedRooms = async () => {
|
||||
try {
|
||||
setIsLoadingRooms(true);
|
||||
setError(null);
|
||||
const response = await roomService.getFeaturedRooms({
|
||||
featured: true,
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
// Handle both response formats
|
||||
if (
|
||||
response.success ||
|
||||
response.status === 'success'
|
||||
) {
|
||||
const rooms = response.data?.rooms || [];
|
||||
setFeaturedRooms(rooms);
|
||||
// If no rooms found but request succeeded, don't show error
|
||||
if (rooms.length === 0) {
|
||||
setError(null);
|
||||
}
|
||||
} else {
|
||||
// Response didn't indicate success
|
||||
setError(
|
||||
response.message ||
|
||||
'Unable to load room list'
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching rooms:', err);
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (err.response?.status === 429) {
|
||||
setError(
|
||||
'Too many requests. Please wait a moment and refresh the page.'
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
'Unable to load room list'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingRooms(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFeaturedRooms();
|
||||
}, []);
|
||||
|
||||
// Fetch newest rooms
|
||||
useEffect(() => {
|
||||
const fetchNewestRooms = async () => {
|
||||
try {
|
||||
setIsLoadingNewest(true);
|
||||
const response = await roomService.getRooms({
|
||||
page: 1,
|
||||
limit: 6,
|
||||
sort: 'newest',
|
||||
});
|
||||
|
||||
// Handle both response formats
|
||||
if (
|
||||
response.success ||
|
||||
response.status === 'success'
|
||||
) {
|
||||
setNewestRooms(response.data?.rooms || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching newest rooms:', err);
|
||||
// Silently fail for newest rooms section - not critical
|
||||
} finally {
|
||||
setIsLoadingNewest(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNewestRooms();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Banner Section */}
|
||||
<section className="container mx-auto px-4 pb-8">
|
||||
{isLoadingBanners ? (
|
||||
<BannerSkeleton />
|
||||
) : (
|
||||
<BannerCarousel banners={banners} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Search Section */}
|
||||
<section className="container mx-auto px-4 py-8">
|
||||
<SearchRoomForm />
|
||||
</section>
|
||||
|
||||
{/* Featured Rooms Section */}
|
||||
<section className="container mx-auto px-4 py-12">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2
|
||||
className="text-3xl font-bold
|
||||
text-gray-900"
|
||||
>
|
||||
Featured Rooms
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hidden md:flex items-center gap-2
|
||||
text-indigo-600 hover:text-indigo-700
|
||||
font-semibold transition-colors"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoadingRooms && (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoadingRooms && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-6 text-center"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-red-600
|
||||
text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rooms Grid */}
|
||||
{!isLoadingRooms && !error && (
|
||||
<>
|
||||
{featuredRooms.length > 0 ? (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{featuredRooms.map((room) => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-gray-100 rounded-lg
|
||||
p-12 text-center"
|
||||
>
|
||||
<p className="text-gray-600 text-lg">
|
||||
No featured rooms available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Button (Mobile) */}
|
||||
{featuredRooms.length > 0 && (
|
||||
<div className="mt-8 text-center md:hidden">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center gap-2
|
||||
bg-indigo-600 text-white px-6 py-3
|
||||
rounded-lg hover:bg-indigo-700
|
||||
transition-colors font-semibold"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Newest Rooms Section */}
|
||||
<section className="container mx-auto px-4 py-12">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2
|
||||
className="text-3xl font-bold
|
||||
text-gray-900"
|
||||
>
|
||||
Newest Rooms
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="hidden md:flex items-center gap-2
|
||||
text-indigo-600 hover:text-indigo-700
|
||||
font-semibold transition-colors"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoadingNewest && (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rooms Grid */}
|
||||
{!isLoadingNewest && (
|
||||
<>
|
||||
{newestRooms.length > 0 ? (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{newestRooms.map((room) => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-gray-100 rounded-lg
|
||||
p-12 text-center"
|
||||
>
|
||||
<p className="text-gray-600 text-lg">
|
||||
No new rooms available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Button (Mobile) */}
|
||||
{newestRooms.length > 0 && (
|
||||
<div className="mt-8 text-center md:hidden">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center gap-2
|
||||
bg-indigo-600 text-white px-6 py-3
|
||||
rounded-lg hover:bg-indigo-700
|
||||
transition-colors font-semibold"
|
||||
>
|
||||
View All Rooms
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section
|
||||
className="container mx-auto px-4 py-12
|
||||
bg-white rounded-xl shadow-sm mx-4"
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-3
|
||||
gap-8"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-indigo-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<span className="text-3xl">🏨</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
Easy Booking
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Search and book rooms with just a few clicks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<span className="text-3xl">💰</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
Best Prices
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Best price guarantee in the market
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-blue-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<span className="text-3xl">🎧</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
24/7 Support
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Support team always ready to serve
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
314
Frontend/src/pages/admin/BookingManagementPage.tsx
Normal file
314
Frontend/src/pages/admin/BookingManagementPage.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search, Eye, XCircle, CheckCircle } from 'lucide-react';
|
||||
import { bookingService, Booking } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
|
||||
const BookingManagementPage: React.FC = () => {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await bookingService.getAllBookings({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setBookings(response.data.bookings);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load bookings list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (id: number, status: string) => {
|
||||
try {
|
||||
await bookingService.updateBooking(id, { status } as any);
|
||||
toast.success('Status updated successfully');
|
||||
fetchBookings();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to update status');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelBooking = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to cancel this booking?')) return;
|
||||
|
||||
try {
|
||||
await bookingService.cancelBooking(id);
|
||||
toast.success('Booking cancelled successfully');
|
||||
fetchBookings();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to cancel booking');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string }> = {
|
||||
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending confirmation' },
|
||||
confirmed: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Confirmed' },
|
||||
checked_in: { bg: 'bg-green-100', text: 'text-green-800', label: 'Checked in' },
|
||||
checked_out: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Checked out' },
|
||||
cancelled: { bg: 'bg-red-100', text: 'text-red-800', label: 'Cancelled' },
|
||||
};
|
||||
const badge = badges[status] || badges.pending;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Booking Management</h1>
|
||||
<p className="text-gray-500 mt-1">Manage bookings</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by booking number, guest name..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">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 className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Booking Number
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Customer
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Room
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Check-in/out
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Total Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{bookings.map((booking) => (
|
||||
<tr key={booking.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-blue-600">{booking.booking_number}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{booking.guest_info?.full_name || booking.user?.name}</div>
|
||||
<div className="text-xs text-gray-500">{booking.guest_info?.email || booking.user?.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
Room {booking.room?.room_number} - {booking.room?.room_type?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{new Date(booking.check_in_date).toLocaleDateString('en-US')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
to {new Date(booking.check_out_date).toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{formatCurrency(booking.total_price)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(booking.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedBooking(booking);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 mr-2"
|
||||
title="View details"
|
||||
>
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
{booking.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(booking.id, 'confirmed')}
|
||||
className="text-green-600 hover:text-green-900 mr-2"
|
||||
title="Confirm"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancelBooking(booking.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
title="Cancel"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{booking.status === 'confirmed' && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(booking.id, 'checked_in')}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
title="Check-in"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showDetailModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Booking Details</h2>
|
||||
<button onClick={() => setShowDetailModal(false)} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Booking Number</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.booking_number}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Status</label>
|
||||
<div className="mt-1">{getStatusBadge(selectedBooking.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Customer Information</label>
|
||||
<p className="text-gray-900">{selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</p>
|
||||
<p className="text-gray-600">{selectedBooking.guest_info?.email || selectedBooking.user?.email}</p>
|
||||
<p className="text-gray-600">{selectedBooking.guest_info?.phone || selectedBooking.user?.phone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Room Information</label>
|
||||
<p className="text-gray-900">Room {selectedBooking.room?.room_number} - {selectedBooking.room?.room_type?.name}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Check-in Date</label>
|
||||
<p className="text-gray-900">{new Date(selectedBooking.check_in_date).toLocaleDateString('en-US')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Check-out Date</label>
|
||||
<p className="text-gray-900">{new Date(selectedBooking.check_out_date).toLocaleDateString('en-US')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Number of Guests</label>
|
||||
<p className="text-gray-900">{selectedBooking.guest_count} guest(s)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Total Price</label>
|
||||
<p className="text-2xl font-bold text-green-600">{formatCurrency(selectedBooking.total_price)}</p>
|
||||
</div>
|
||||
{selectedBooking.notes && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Notes</label>
|
||||
<p className="text-gray-900">{selectedBooking.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingManagementPage;
|
||||
402
Frontend/src/pages/admin/CheckInPage.tsx
Normal file
402
Frontend/src/pages/admin/CheckInPage.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, User, Hotel, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { bookingService, Booking } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
|
||||
interface GuestInfo {
|
||||
name: string;
|
||||
id_number: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
const CheckInPage: React.FC = () => {
|
||||
const [bookingNumber, setBookingNumber] = useState('');
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [actualRoomNumber, setActualRoomNumber] = useState('');
|
||||
const [guests, setGuests] = useState<GuestInfo[]>([{ name: '', id_number: '', phone: '' }]);
|
||||
const [extraPersons, setExtraPersons] = useState(0);
|
||||
const [children, setChildren] = useState(0);
|
||||
const [additionalFee, setAdditionalFee] = useState(0);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!bookingNumber.trim()) {
|
||||
toast.error('Please enter booking number');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSearching(true);
|
||||
const response = await bookingService.checkBookingByNumber(bookingNumber);
|
||||
setBooking(response.data.booking);
|
||||
setActualRoomNumber(response.data.booking.room?.room_number || '');
|
||||
toast.success('Booking found');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Booking not found');
|
||||
setBooking(null);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGuest = () => {
|
||||
setGuests([...guests, { name: '', id_number: '', phone: '' }]);
|
||||
};
|
||||
|
||||
const handleRemoveGuest = (index: number) => {
|
||||
if (guests.length > 1) {
|
||||
setGuests(guests.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleGuestChange = (index: number, field: keyof GuestInfo, value: string) => {
|
||||
const newGuests = [...guests];
|
||||
newGuests[index][field] = value;
|
||||
setGuests(newGuests);
|
||||
};
|
||||
|
||||
const calculateAdditionalFee = () => {
|
||||
// Logic to calculate additional fees: children and extra person
|
||||
const extraPersonFee = extraPersons * 200000; // 200k/person
|
||||
const childrenFee = children * 100000; // 100k/child
|
||||
const total = extraPersonFee + childrenFee;
|
||||
setAdditionalFee(total);
|
||||
return total;
|
||||
};
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
if (!booking) return;
|
||||
|
||||
// Validate
|
||||
if (!actualRoomNumber.trim()) {
|
||||
toast.error('Please enter actual room number');
|
||||
return;
|
||||
}
|
||||
|
||||
const mainGuest = guests[0];
|
||||
if (!mainGuest.name || !mainGuest.id_number || !mainGuest.phone) {
|
||||
toast.error('Please fill in all main guest information');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Calculate additional fee
|
||||
calculateAdditionalFee();
|
||||
|
||||
await bookingService.updateBooking(booking.id, {
|
||||
status: 'checked_in',
|
||||
// Can send additional data about guests, room_number, additional_fee
|
||||
} as any);
|
||||
|
||||
toast.success('Check-in successful');
|
||||
|
||||
// Reset form
|
||||
setBooking(null);
|
||||
setBookingNumber('');
|
||||
setActualRoomNumber('');
|
||||
setGuests([{ name: '', id_number: '', phone: '' }]);
|
||||
setExtraPersons(0);
|
||||
setChildren(0);
|
||||
setAdditionalFee(0);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred during check-in');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Check-in</h1>
|
||||
<p className="text-gray-500 mt-1">Customer check-in process</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Booking */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
value={bookingNumber}
|
||||
onChange={(e) => setBookingNumber(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Enter booking number"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
|
||||
>
|
||||
{searching ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Info */}
|
||||
{booking && (
|
||||
<>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
2. Booking Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Booking Number:</span>
|
||||
<span className="font-semibold">{booking.booking_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Customer:</span>
|
||||
<span className="font-semibold">{booking.user?.full_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Email:</span>
|
||||
<span>{booking.user?.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Phone:</span>
|
||||
<span>{booking.user?.phone_number || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Room Type:</span>
|
||||
<span className="font-semibold">{booking.room?.room_type?.name || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Check-in:</span>
|
||||
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Check-out:</span>
|
||||
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Number of Guests:</span>
|
||||
<span>{booking.guest_count} guest(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.status !== 'confirmed' && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-yellow-800 font-medium">Warning</p>
|
||||
<p className="text-sm text-yellow-700">
|
||||
Booking status: <span className="font-semibold">{booking.status}</span>.
|
||||
Only check-in confirmed bookings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assign Room */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Hotel className="w-5 h-5 text-blue-600" />
|
||||
3. Assign Actual Room Number
|
||||
</h2>
|
||||
<div className="max-w-md">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Room Number <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={actualRoomNumber}
|
||||
onChange={(e) => setActualRoomNumber(e.target.value)}
|
||||
placeholder="e.g: 101, 202, 305"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Enter the actual room number to assign to the guest
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Information */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-purple-600" />
|
||||
4. Guest Information
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{guests.map((guest, index) => (
|
||||
<div key={index} className="p-4 border border-gray-200 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium">
|
||||
{index === 0 ? 'Main Guest' : `Guest ${index + 1}`}
|
||||
{index === 0 && <span className="text-red-500 ml-1">*</span>}
|
||||
</h3>
|
||||
{index > 0 && (
|
||||
<button
|
||||
onClick={() => handleRemoveGuest(index)}
|
||||
className="text-red-600 hover:text-red-800 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
Full Name {index === 0 && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={guest.name}
|
||||
onChange={(e) => handleGuestChange(index, 'name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
ID Number {index === 0 && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={guest.id_number}
|
||||
onChange={(e) => handleGuestChange(index, 'id_number', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="001234567890"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
Phone Number {index === 0 && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={guest.phone}
|
||||
onChange={(e) => handleGuestChange(index, 'phone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0912345678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={handleAddGuest}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
+ Add Guest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Charges */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">5. Additional Fees (if any)</h2>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Extra Persons
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={extraPersons}
|
||||
onChange={(e) => {
|
||||
setExtraPersons(parseInt(e.target.value) || 0);
|
||||
calculateAdditionalFee();
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">€50/person</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Number of Children
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={children}
|
||||
onChange={(e) => {
|
||||
setChildren(parseInt(e.target.value) || 0);
|
||||
calculateAdditionalFee();
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">€25/child</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Total Additional Fee
|
||||
</label>
|
||||
<div className="px-4 py-2 bg-gray-50 border border-gray-300 rounded-lg text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(calculateAdditionalFee())}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary & Action */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Confirm Check-in</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Guest: <span className="font-medium">{booking.user?.full_name}</span> |
|
||||
Room: <span className="font-medium">{actualRoomNumber || 'Not assigned'}</span>
|
||||
{additionalFee > 0 && (
|
||||
<> | Additional Fee: <span className="font-medium text-red-600">{formatCurrency(additionalFee)}</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckIn}
|
||||
disabled={!actualRoomNumber || !guests[0].name || booking.status !== 'confirmed'}
|
||||
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Confirm Check-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!booking && !searching && (
|
||||
<div className="bg-gray-50 rounded-lg p-12 text-center">
|
||||
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No booking selected
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Please enter booking number above to start check-in process
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckInPage;
|
||||
448
Frontend/src/pages/admin/CheckOutPage.tsx
Normal file
448
Frontend/src/pages/admin/CheckOutPage.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, FileText, DollarSign, CreditCard, Printer, CheckCircle } from 'lucide-react';
|
||||
import { bookingService, Booking } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
|
||||
interface ServiceItem {
|
||||
service_name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CheckOutPage: React.FC = () => {
|
||||
const [bookingNumber, setBookingNumber] = useState('');
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [services, setServices] = useState<ServiceItem[]>([]);
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'bank_transfer' | 'credit_card'>('cash');
|
||||
const [discount, setDiscount] = useState(0);
|
||||
const [showInvoice, setShowInvoice] = useState(false);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!bookingNumber.trim()) {
|
||||
toast.error('Please enter booking number');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSearching(true);
|
||||
const response = await bookingService.checkBookingByNumber(bookingNumber);
|
||||
const foundBooking = response.data.booking;
|
||||
|
||||
if (foundBooking.status !== 'checked_in') {
|
||||
toast.warning('Only checked-in bookings can be checked out');
|
||||
}
|
||||
|
||||
setBooking(foundBooking);
|
||||
|
||||
// Mock services data - in production will fetch from API
|
||||
setServices([
|
||||
{ service_name: 'Laundry', quantity: 2, price: 50000, total: 100000 },
|
||||
{ service_name: 'Minibar', quantity: 1, price: 150000, total: 150000 },
|
||||
]);
|
||||
|
||||
toast.success('Booking found');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Booking not found');
|
||||
setBooking(null);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRoomFee = () => {
|
||||
if (!booking) return 0;
|
||||
return booking.total_price || 0;
|
||||
};
|
||||
|
||||
const calculateServiceFee = () => {
|
||||
return services.reduce((sum, service) => sum + service.total, 0);
|
||||
};
|
||||
|
||||
const calculateAdditionalFee = () => {
|
||||
// Additional fees from check-in (children, extra person)
|
||||
return 0; // In production will get from booking data
|
||||
};
|
||||
|
||||
const calculateDeposit = () => {
|
||||
// Deposit already paid
|
||||
return booking?.total_price ? booking.total_price * 0.3 : 0;
|
||||
};
|
||||
|
||||
const calculateSubtotal = () => {
|
||||
return calculateRoomFee() + calculateServiceFee() + calculateAdditionalFee();
|
||||
};
|
||||
|
||||
const calculateDiscount = () => {
|
||||
return discount;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
return calculateSubtotal() - calculateDiscount();
|
||||
};
|
||||
|
||||
const calculateRemaining = () => {
|
||||
return calculateTotal() - calculateDeposit();
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
|
||||
};
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
if (!booking) return;
|
||||
|
||||
if (calculateRemaining() < 0) {
|
||||
toast.error('Invalid refund amount');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Update booking status
|
||||
await bookingService.updateBooking(booking.id, {
|
||||
status: 'checked_out',
|
||||
} as any);
|
||||
|
||||
// Create payment record (if needed)
|
||||
// await paymentService.createPayment({...});
|
||||
|
||||
toast.success('Check-out successful');
|
||||
setShowInvoice(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred during check-out');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintInvoice = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setBooking(null);
|
||||
setBookingNumber('');
|
||||
setServices([]);
|
||||
setDiscount(0);
|
||||
setPaymentMethod('cash');
|
||||
setShowInvoice(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Check-out</h1>
|
||||
<p className="text-gray-500 mt-1">Payment and check-out process</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Booking */}
|
||||
{!showInvoice && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
value={bookingNumber}
|
||||
onChange={(e) => setBookingNumber(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Enter booking number or room number"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
|
||||
>
|
||||
{searching ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice */}
|
||||
{booking && !showInvoice && (
|
||||
<>
|
||||
{/* Booking Info */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">2. Booking information</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Booking number:</span>
|
||||
<span className="font-semibold">{booking.booking_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Customer:</span>
|
||||
<span className="font-semibold">{booking.user?.full_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Room number:</span>
|
||||
<span className="font-semibold">{booking.room?.room_number}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Check-in:</span>
|
||||
<span>{booking.check_in_date ? new Date(booking.check_in_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Check-out:</span>
|
||||
<span>{booking.check_out_date ? new Date(booking.check_out_date).toLocaleDateString('en-US') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Nights:</span>
|
||||
<span>
|
||||
{booking.check_in_date && booking.check_out_date
|
||||
? Math.ceil((new Date(booking.check_out_date).getTime() - new Date(booking.check_in_date).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 0} night(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bill Details */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
3. Invoice details
|
||||
</h2>
|
||||
|
||||
{/* Room Fee */}
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Room fee</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="flex justify-between">
|
||||
<span>{booking.room?.room_type?.name || 'Room'}</span>
|
||||
<span className="font-semibold">{formatCurrency(calculateRoomFee())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Fee */}
|
||||
{services.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Services used</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-lg space-y-2">
|
||||
{services.map((service, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span>
|
||||
{service.service_name} (x{service.quantity})
|
||||
</span>
|
||||
<span>{formatCurrency(service.total)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 border-t border-gray-200 flex justify-between font-medium">
|
||||
<span>Total services:</span>
|
||||
<span>{formatCurrency(calculateServiceFee())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Fee */}
|
||||
{calculateAdditionalFee() > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Additional fees</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="flex justify-between">
|
||||
<span>Extra person/children fee</span>
|
||||
<span className="font-semibold">{formatCurrency(calculateAdditionalFee())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discount */}
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Discount</h3>
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="number"
|
||||
value={discount}
|
||||
onChange={(e) => setDiscount(parseFloat(e.target.value) || 0)}
|
||||
placeholder="Enter discount amount"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="border-t-2 border-gray-300 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-lg">
|
||||
<span>Subtotal:</span>
|
||||
<span className="font-semibold">{formatCurrency(calculateSubtotal())}</span>
|
||||
</div>
|
||||
{discount > 0 && (
|
||||
<div className="flex justify-between text-red-600">
|
||||
<span>Discount:</span>
|
||||
<span>-{formatCurrency(discount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xl font-bold text-blue-600">
|
||||
<span>Total:</span>
|
||||
<span>{formatCurrency(calculateTotal())}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Deposit paid:</span>
|
||||
<span>-{formatCurrency(calculateDeposit())}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-2xl font-bold text-green-600 pt-2 border-t border-gray-200">
|
||||
<span>Remaining payment:</span>
|
||||
<span>{formatCurrency(calculateRemaining())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-green-600" />
|
||||
4. Payment method
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => setPaymentMethod('cash')}
|
||||
className={`p-4 border-2 rounded-lg text-center transition-all ${
|
||||
paymentMethod === 'cash'
|
||||
? 'border-blue-600 bg-blue-50 text-blue-600'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<DollarSign className="w-8 h-8 mx-auto mb-2" />
|
||||
<div className="font-medium">Cash</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPaymentMethod('bank_transfer')}
|
||||
className={`p-4 border-2 rounded-lg text-center transition-all ${
|
||||
paymentMethod === 'bank_transfer'
|
||||
? 'border-blue-600 bg-blue-50 text-blue-600'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-8 h-8 mx-auto mb-2" />
|
||||
<div className="font-medium">Bank transfer</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPaymentMethod('credit_card')}
|
||||
className={`p-4 border-2 rounded-lg text-center transition-all ${
|
||||
paymentMethod === 'credit_card'
|
||||
? 'border-blue-600 bg-blue-50 text-blue-600'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-8 h-8 mx-auto mb-2" />
|
||||
<div className="font-medium">Credit card</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-6 rounded-lg border border-green-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Confirm check-out</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Total payment: <span className="font-bold text-green-600 text-lg">{formatCurrency(calculateRemaining())}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckOut}
|
||||
disabled={booking.status !== 'checked_in'}
|
||||
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Confirm payment & Check-out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Invoice Display */}
|
||||
{showInvoice && booking && (
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">PAYMENT INVOICE</h2>
|
||||
<p className="text-gray-600 mt-1">Check-out successful</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t-2 border-b-2 border-gray-300 py-6 mb-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Booking number:</p>
|
||||
<p className="font-semibold">{booking.booking_number}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Check-out date:</p>
|
||||
<p className="font-semibold">{new Date().toLocaleString('en-US')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Customer:</p>
|
||||
<p className="font-semibold">{booking.user?.full_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Payment method:</p>
|
||||
<p className="font-semibold">
|
||||
{paymentMethod === 'cash' ? 'Cash' : paymentMethod === 'bank_transfer' ? 'Bank transfer' : 'Credit card'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-xl font-bold text-green-600 mb-4">
|
||||
<span>Total payment:</span>
|
||||
<span>{formatCurrency(calculateRemaining())}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handlePrintInvoice}
|
||||
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
Print invoice
|
||||
</button>
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="flex-1 px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!booking && !searching && !showInvoice && (
|
||||
<div className="bg-gray-50 rounded-lg p-12 text-center">
|
||||
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No booking selected
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Please enter booking number to start check-out process
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckOutPage;
|
||||
290
Frontend/src/pages/admin/DashboardPage.tsx
Normal file
290
Frontend/src/pages/admin/DashboardPage.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
Hotel,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { reportService, ReportData } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [stats, setStats] = useState<ReportData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
to: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [dateRange]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await reportService.getReports({
|
||||
from: dateRange.from,
|
||||
to: dateRange.to,
|
||||
});
|
||||
setStats(response.data);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load dashboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">Hotel operations overview</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-500">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* Total Revenue */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Total Revenue</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||
{formatCurrency(stats?.total_revenue || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-100 p-3 rounded-full">
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-4 text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
||||
<span className="text-green-600 font-medium">+12.5%</span>
|
||||
<span className="text-gray-500 ml-2">compared to last month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Bookings */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Total Bookings</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||
{stats?.total_bookings || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-100 p-3 rounded-full">
|
||||
<Calendar className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-4 text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
||||
<span className="text-green-600 font-medium">+8.2%</span>
|
||||
<span className="text-gray-500 ml-2">compared to last month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Rooms */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Available Rooms</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||
{stats?.available_rooms || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-100 p-3 rounded-full">
|
||||
<Hotel className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-4 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{stats?.occupied_rooms || 0} rooms in use
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Customers */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm font-medium">Customers</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||
{stats?.total_customers || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-orange-100 p-3 rounded-full">
|
||||
<Users className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-4 text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
||||
<span className="text-green-600 font-medium">+15.3%</span>
|
||||
<span className="text-gray-500 ml-2">new customers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Chart */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Daily Revenue</h2>
|
||||
<BarChart3 className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
{stats?.revenue_by_date && stats.revenue_by_date.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{stats.revenue_by_date.slice(0, 7).map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="text-sm text-gray-600 w-24">
|
||||
{new Date(item.date).toLocaleDateString('en-US')}
|
||||
</span>
|
||||
<div className="flex-1 mx-3">
|
||||
<div className="bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500 h-4 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min((item.revenue / (stats.revenue_by_date?.[0]?.revenue || 1)) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
|
||||
{formatCurrency(item.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Status</h2>
|
||||
{stats?.bookings_by_status ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(stats.bookings_by_status).map(([status, count]) => {
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-500',
|
||||
confirmed: 'bg-blue-500',
|
||||
checked_in: 'bg-green-500',
|
||||
checked_out: 'bg-gray-500',
|
||||
cancelled: 'bg-red-500',
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pending confirmation',
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked in',
|
||||
checked_out: 'Checked out',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${statusColors[status]}`} />
|
||||
<span className="text-gray-700">{statusLabels[status]}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Rooms and Services */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Rooms */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Booked Rooms</h2>
|
||||
{stats?.top_rooms && stats.top_rooms.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{stats.top_rooms.map((room, index) => (
|
||||
<div key={room.room_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center justify-center w-8 h-8 bg-blue-500 text-white rounded-full font-bold">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Room {room.room_number}</p>
|
||||
<p className="text-sm text-gray-500">{room.bookings} bookings</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatCurrency(room.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Usage */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Services Used</h2>
|
||||
{stats?.service_usage && stats.service_usage.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{stats.service_usage.map((service) => (
|
||||
<div key={service.service_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{service.service_name}</p>
|
||||
<p className="text-sm text-gray-500">{service.usage_count} times used</p>
|
||||
</div>
|
||||
<span className="font-semibold text-purple-600">
|
||||
{formatCurrency(service.total_revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
195
Frontend/src/pages/admin/PaymentManagementPage.tsx
Normal file
195
Frontend/src/pages/admin/PaymentManagementPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { paymentService, Payment } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
|
||||
const PaymentManagementPage: React.FC = () => {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
method: '',
|
||||
from: '',
|
||||
to: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayments();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await paymentService.getPayments({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setPayments(response.data.payments);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load payments list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string }> = {
|
||||
cash: { bg: 'bg-green-100', text: 'text-green-800', label: 'Cash' },
|
||||
bank_transfer: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Bank transfer' },
|
||||
credit_card: { bg: 'bg-purple-100', text: 'text-purple-800', label: 'Credit card' },
|
||||
};
|
||||
const badge = badges[method] || badges.cash;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Payment Management</h1>
|
||||
<p className="text-gray-500 mt-1">Track payment transactions</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.method}
|
||||
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All methods</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="bank_transfer">Bank transfer</option>
|
||||
<option value="credit_card">Credit card</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="From date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="To date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Transaction ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Booking Number
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Customer
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Method
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Payment Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{payment.transaction_id || `PAY-${payment.id}`}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-blue-600">{payment.booking?.booking_number}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{payment.booking?.user?.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getMethodBadge(payment.payment_method)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-green-600">
|
||||
{formatCurrency(payment.amount)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(payment.payment_date || payment.createdAt).toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg shadow-lg p-6 text-white">
|
||||
<h3 className="text-lg font-semibold mb-2">Total Revenue</h3>
|
||||
<p className="text-3xl font-bold">
|
||||
{formatCurrency(payments.reduce((sum, p) => sum + p.amount, 0))}
|
||||
</p>
|
||||
<p className="text-sm mt-2 opacity-90">Total {payments.length} transactions</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentManagementPage;
|
||||
488
Frontend/src/pages/admin/PromotionManagementPage.tsx
Normal file
488
Frontend/src/pages/admin/PromotionManagementPage.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
|
||||
import { promotionService, Promotion } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
|
||||
const PromotionManagementPage: React.FC = () => {
|
||||
const [promotions, setPromotions] = useState<Promotion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
type: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
discount_type: 'percentage' as 'percentage' | 'fixed',
|
||||
discount_value: 0,
|
||||
min_booking_amount: 0,
|
||||
max_discount_amount: 0,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
usage_limit: 0,
|
||||
status: 'active' as 'active' | 'inactive' | 'expired',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPromotions();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchPromotions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await promotionService.getPromotions({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setPromotions(response.data.promotions);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load promotions list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingPromotion) {
|
||||
await promotionService.updatePromotion(editingPromotion.id, formData);
|
||||
toast.success('Promotion updated successfully');
|
||||
} else {
|
||||
await promotionService.createPromotion(formData);
|
||||
toast.success('Promotion added successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchPromotions();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (promotion: Promotion) => {
|
||||
setEditingPromotion(promotion);
|
||||
setFormData({
|
||||
code: promotion.code,
|
||||
name: promotion.name,
|
||||
description: promotion.description || '',
|
||||
discount_type: promotion.discount_type,
|
||||
discount_value: promotion.discount_value,
|
||||
min_booking_amount: promotion.min_booking_amount || 0,
|
||||
max_discount_amount: promotion.max_discount_amount || 0,
|
||||
start_date: promotion.start_date?.split('T')[0] || '',
|
||||
end_date: promotion.end_date?.split('T')[0] || '',
|
||||
usage_limit: promotion.usage_limit || 0,
|
||||
status: promotion.status,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this promotion?')) return;
|
||||
|
||||
try {
|
||||
await promotionService.deletePromotion(id);
|
||||
toast.success('Promotion deleted successfully');
|
||||
fetchPromotions();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete promotion');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingPromotion(null);
|
||||
setFormData({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
discount_type: 'percentage',
|
||||
discount_value: 0,
|
||||
min_booking_amount: 0,
|
||||
max_discount_amount: 0,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
usage_limit: 0,
|
||||
status: 'active',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string }> = {
|
||||
active: { bg: 'bg-green-100', text: 'text-green-800', label: 'Active' },
|
||||
inactive: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Inactive' },
|
||||
};
|
||||
const badge = badges[status] || badges.active;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Promotion Management</h1>
|
||||
<p className="text-gray-500 mt-1">Manage discount codes and promotion programs</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Promotion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code or name..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="percentage">Percentage</option>
|
||||
<option value="fixed">Fixed Amount</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Code
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Program Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Value
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Period
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Used
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{promotions.map((promotion) => (
|
||||
<tr key={promotion.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-mono font-bold text-blue-600">{promotion.code}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">{promotion.name}</div>
|
||||
<div className="text-xs text-gray-500">{promotion.description}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{promotion.discount_type === 'percentage'
|
||||
? `${promotion.discount_value}%`
|
||||
: formatCurrency(promotion.discount_value)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-xs text-gray-500">
|
||||
{promotion.start_date ? new Date(promotion.start_date).toLocaleDateString('en-US') : 'N/A'}
|
||||
{' → '}
|
||||
{promotion.end_date ? new Date(promotion.end_date).toLocaleDateString('en-US') : 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{promotion.used_count || 0} / {promotion.usage_limit || '∞'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(promotion.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(promotion)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(promotion.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">
|
||||
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
||||
</h2>
|
||||
<button onClick={() => setShowModal(false)}>
|
||||
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Code <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g: SUMMER2024"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Program Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g: Summer Sale"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="Detailed description of the program..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Discount Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.discount_type}
|
||||
onChange={(e) => setFormData({ ...formData, discount_type: e.target.value as 'percentage' | 'fixed' })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="percentage">Percentage (%)</option>
|
||||
<option value="fixed">Fixed Amount (EUR)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Discount Value <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.discount_value}
|
||||
onChange={(e) => setFormData({ ...formData, discount_value: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
max={formData.discount_type === 'percentage' ? 100 : undefined}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Minimum Order Value (EUR)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.min_booking_amount}
|
||||
onChange={(e) => setFormData({ ...formData, min_booking_amount: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Maximum Discount (EUR)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.max_discount_amount}
|
||||
onChange={(e) => setFormData({ ...formData, max_discount_amount: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.start_date}
|
||||
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.end_date}
|
||||
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Usage Limit (0 = unlimited)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.usage_limit}
|
||||
onChange={(e) => setFormData({ ...formData, usage_limit: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as 'active' | 'inactive' })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
{editingPromotion ? 'Update' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromotionManagementPage;
|
||||
206
Frontend/src/pages/admin/ReviewManagementPage.tsx
Normal file
206
Frontend/src/pages/admin/ReviewManagementPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { reviewService, Review } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
|
||||
const ReviewManagementPage: React.FC = () => {
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReviews();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchReviews = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await reviewService.getReviews({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setReviews(response.data.reviews);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load reviews list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
try {
|
||||
await reviewService.approveReview(id);
|
||||
toast.success('Review approved successfully');
|
||||
fetchReviews();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to approve review');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to reject this review?')) return;
|
||||
|
||||
try {
|
||||
await reviewService.rejectReview(id);
|
||||
toast.success('Review rejected successfully');
|
||||
fetchReviews();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to reject review');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string }> = {
|
||||
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending' },
|
||||
approved: { bg: 'bg-green-100', text: 'text-green-800', label: 'Approved' },
|
||||
rejected: { bg: 'bg-red-100', text: 'text-red-800', label: 'Rejected' },
|
||||
};
|
||||
const badge = badges[status] || badges.pending;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span key={star} className={star <= rating ? 'text-yellow-400' : 'text-gray-300'}>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Review Management</h1>
|
||||
<p className="text-gray-500 mt-1">Approve and manage customer reviews</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Room
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Rating
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Comment
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Created Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reviews.map((review) => (
|
||||
<tr key={review.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{review.user?.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
Room {review.room?.room_number} - {review.room?.room_type?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderStars(review.rating)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900 max-w-xs truncate">{review.comment}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(review.created_at).toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(review.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{review.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApprove(review.id)}
|
||||
className="text-green-600 hover:text-green-900 mr-3"
|
||||
title="Approve"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(review.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
title="Reject"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewManagementPage;
|
||||
512
Frontend/src/pages/admin/RoomManagementPage.tsx
Normal file
512
Frontend/src/pages/admin/RoomManagementPage.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon } from 'lucide-react';
|
||||
import { roomService, Room } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import apiClient from '../../services/api/apiClient';
|
||||
|
||||
const RoomManagementPage: React.FC = () => {
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
type: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
room_number: '',
|
||||
floor: 1,
|
||||
room_type_id: 1,
|
||||
status: 'available' as 'available' | 'occupied' | 'maintenance',
|
||||
featured: false,
|
||||
});
|
||||
|
||||
const [uploadingImages, setUploadingImages] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await roomService.getRooms({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setRooms(response.data.rooms);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load rooms list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingRoom) {
|
||||
// Update room
|
||||
await roomService.updateRoom(editingRoom.id, formData);
|
||||
toast.success('Room updated successfully');
|
||||
} else {
|
||||
// Create room
|
||||
await roomService.createRoom(formData);
|
||||
toast.success('Room added successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchRooms();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (room: Room) => {
|
||||
setEditingRoom(room);
|
||||
setFormData({
|
||||
room_number: room.room_number,
|
||||
floor: room.floor,
|
||||
room_type_id: room.room_type_id,
|
||||
status: room.status,
|
||||
featured: room.featured,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this room?')) return;
|
||||
|
||||
try {
|
||||
await roomService.deleteRoom(id);
|
||||
toast.success('Room deleted successfully');
|
||||
fetchRooms();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete room');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingRoom(null);
|
||||
setFormData({
|
||||
room_number: '',
|
||||
floor: 1,
|
||||
room_type_id: 1,
|
||||
status: 'available',
|
||||
featured: false,
|
||||
});
|
||||
setSelectedFiles([]);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
setSelectedFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadImages = async () => {
|
||||
if (!editingRoom || selectedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
setUploadingImages(true);
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
|
||||
await apiClient.post(`/rooms/${editingRoom.id}/images`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Images uploaded successfully');
|
||||
setSelectedFiles([]);
|
||||
fetchRooms();
|
||||
|
||||
// Refresh editing room data
|
||||
const response = await roomService.getRoomById(editingRoom.id);
|
||||
setEditingRoom(response.data.room);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to upload images');
|
||||
} finally {
|
||||
setUploadingImages(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImage = async (imageUrl: string) => {
|
||||
if (!editingRoom) return;
|
||||
if (!window.confirm('Are you sure you want to delete this image?')) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
|
||||
data: { imageUrl },
|
||||
});
|
||||
|
||||
toast.success('Image deleted successfully');
|
||||
fetchRooms();
|
||||
|
||||
// Refresh editing room data
|
||||
const response = await roomService.getRoomById(editingRoom.id);
|
||||
setEditingRoom(response.data.room);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete image');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string }> = {
|
||||
available: { bg: 'bg-green-100', text: 'text-green-800', label: 'Available' },
|
||||
occupied: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Occupied' },
|
||||
maintenance: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Maintenance' },
|
||||
};
|
||||
const badge = badges[status] || badges.available;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Room Management</h1>
|
||||
<p className="text-gray-500 mt-1">Manage hotel room information</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Room
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search rooms..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="available">Available</option>
|
||||
<option value="occupied">Occupied</option>
|
||||
<option value="maintenance">Maintenance</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Room Types</option>
|
||||
<option value="1">Standard</option>
|
||||
<option value="2">Deluxe</option>
|
||||
<option value="3">Suite</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Room Number
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Room Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Floor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Featured
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{rooms.map((room) => (
|
||||
<tr key={room.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{room.room_number}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{room.room_type?.name || 'N/A'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">Floor {room.floor}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(room.room_type?.base_price || 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(room.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{room.featured ? (
|
||||
<span className="text-yellow-500">⭐</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(room)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(room.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">
|
||||
{editingRoom ? 'Update Room' : 'Add New Room'}
|
||||
</h2>
|
||||
<button onClick={() => setShowModal(false)}>
|
||||
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Room Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.room_number}
|
||||
onChange={(e) => setFormData({ ...formData, room_number: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Floor
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.floor}
|
||||
onChange={(e) => setFormData({ ...formData, floor: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Room Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.room_type_id}
|
||||
onChange={(e) => setFormData({ ...formData, room_type_id: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="1">Standard</option>
|
||||
<option value="2">Deluxe</option>
|
||||
<option value="3">Suite</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="available">Available</option>
|
||||
<option value="occupied">Occupied</option>
|
||||
<option value="maintenance">Maintenance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="featured"
|
||||
checked={formData.featured}
|
||||
onChange={(e) => setFormData({ ...formData, featured: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="featured" className="ml-2 text-sm text-gray-700">
|
||||
Featured Room
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
{editingRoom ? 'Update' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Image Upload Section - Only for editing */}
|
||||
{editingRoom && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
Room Images
|
||||
</h3>
|
||||
|
||||
{/* Current Images */}
|
||||
{editingRoom.room_type?.images && editingRoom.room_type.images.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600 mb-2">Current Images:</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{editingRoom.room_type.images.map((img, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<img
|
||||
src={`http://localhost:8000${img}`}
|
||||
alt={`Room ${index + 1}`}
|
||||
className="w-full h-24 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteImage(img)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload New Images */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Add New Images (max 5 images):
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="flex-1 text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadImages}
|
||||
disabled={selectedFiles.length === 0 || uploadingImages}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploadingImages ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
{selectedFiles.length > 0 && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{selectedFiles.length} file(s) selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomManagementPage;
|
||||
336
Frontend/src/pages/admin/ServiceManagementPage.tsx
Normal file
336
Frontend/src/pages/admin/ServiceManagementPage.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
|
||||
import { serviceService, Service } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
|
||||
const ServiceManagementPage: React.FC = () => {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingService, setEditingService] = useState<Service | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await serviceService.getServices({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setServices(response.data.services);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load services list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingService) {
|
||||
await serviceService.updateService(editingService.id, formData);
|
||||
toast.success('Service updated successfully');
|
||||
} else {
|
||||
await serviceService.createService(formData);
|
||||
toast.success('Service added successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchServices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (service: Service) => {
|
||||
setEditingService(service);
|
||||
setFormData({
|
||||
name: service.name,
|
||||
description: service.description || '',
|
||||
price: service.price,
|
||||
unit: service.unit || 'time',
|
||||
status: service.status,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this service?')) return;
|
||||
|
||||
try {
|
||||
await serviceService.deleteService(id);
|
||||
toast.success('Service deleted successfully');
|
||||
fetchServices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete service');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingService(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
status: 'active',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Service Management</h1>
|
||||
<p className="text-gray-500 mt-1">Manage hotel services</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search services..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Service Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Unit
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{services.map((service) => (
|
||||
<tr key={service.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{service.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900 max-w-xs truncate">{service.description}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-gray-900">{formatCurrency(service.price)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{service.unit}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
service.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{service.status === 'active' ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(service)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(service.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">
|
||||
{editingService ? 'Update Service' : 'Add New Service'}
|
||||
</h2>
|
||||
<button onClick={() => setShowModal(false)}>
|
||||
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Service Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Price
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Unit
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.unit}
|
||||
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g: time, hour, day..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
{editingService ? 'Update' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceManagementPage;
|
||||
412
Frontend/src/pages/admin/UserManagementPage.tsx
Normal file
412
Frontend/src/pages/admin/UserManagementPage.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
|
||||
import { userService, User } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
const UserManagementPage: React.FC = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
role: '',
|
||||
status: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
full_name: '',
|
||||
email: '',
|
||||
phone_number: '',
|
||||
password: '',
|
||||
role: 'customer',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('Fetching users with filters:', filters, 'page:', currentPage);
|
||||
const response = await userService.getUsers({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
console.log('Users response:', response);
|
||||
setUsers(response.data.users);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching users:', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load users list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingUser) {
|
||||
// When updating, only send password if changed
|
||||
const updateData: any = {
|
||||
full_name: formData.full_name,
|
||||
email: formData.email,
|
||||
phone_number: formData.phone_number,
|
||||
role: formData.role,
|
||||
status: formData.status,
|
||||
};
|
||||
|
||||
// Only add password if user entered a new one
|
||||
if (formData.password && formData.password.trim() !== '') {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
|
||||
console.log('Updating user:', editingUser.id, 'with data:', updateData);
|
||||
const response = await userService.updateUser(editingUser.id, updateData);
|
||||
console.log('Update response:', response);
|
||||
toast.success('User updated successfully');
|
||||
} else {
|
||||
// When creating new, need complete information
|
||||
if (!formData.password || formData.password.trim() === '') {
|
||||
toast.error('Please enter password');
|
||||
return;
|
||||
}
|
||||
console.log('Creating user with data:', formData);
|
||||
const response = await userService.createUser(formData);
|
||||
console.log('Create response:', response);
|
||||
toast.success('User added successfully');
|
||||
}
|
||||
|
||||
// Close modal and reset form first
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
|
||||
// Reload users list after a bit to ensure DB is updated
|
||||
setTimeout(() => {
|
||||
fetchUsers();
|
||||
}, 300);
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting user:', error);
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
phone_number: user.phone_number || '',
|
||||
password: '',
|
||||
role: user.role,
|
||||
status: user.status || 'active',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
// Prevent self-deletion
|
||||
if (userInfo?.id === id) {
|
||||
toast.error('You cannot delete your own account');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
try {
|
||||
console.log('Deleting user:', id);
|
||||
await userService.deleteUser(id);
|
||||
toast.success('User deleted successfully');
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting user:', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
full_name: '',
|
||||
email: '',
|
||||
phone_number: '',
|
||||
password: '',
|
||||
role: 'customer',
|
||||
status: 'active',
|
||||
});
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string }> = {
|
||||
admin: { bg: 'bg-red-100', text: 'text-red-800', label: 'Admin' },
|
||||
staff: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Staff' },
|
||||
customer: { bg: 'bg-green-100', text: 'text-green-800', label: 'Customer' },
|
||||
};
|
||||
const badge = badges[role] || badges.customer;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
|
||||
<p className="text-gray-500 mt-1">Manage accounts and permissions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, email..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.role}
|
||||
onChange={(e) => setFilters({ ...filters, role: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="staff">Staff</option>
|
||||
<option value="customer">Customer</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Phone
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Created Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{user.full_name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{user.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{user.phone_number || 'N/A'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getRoleBadge(user.role)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString('en-US') : 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
disabled={userInfo?.id === user.id}
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">
|
||||
{editingUser ? 'Update User' : 'Add New User'}
|
||||
</h2>
|
||||
<button onClick={() => setShowModal(false)}>
|
||||
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone_number}
|
||||
onChange={(e) => setFormData({ ...formData, phone_number: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password {editingUser && '(leave blank if not changing)'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required={!editingUser}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="customer">Customer</option>
|
||||
<option value="staff">Staff</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
{editingUser ? 'Update' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagementPage;
|
||||
10
Frontend/src/pages/admin/index.ts
Normal file
10
Frontend/src/pages/admin/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
export { default as RoomManagementPage } from './RoomManagementPage';
|
||||
export { default as UserManagementPage } from './UserManagementPage';
|
||||
export { default as BookingManagementPage } from './BookingManagementPage';
|
||||
export { default as PaymentManagementPage } from './PaymentManagementPage';
|
||||
export { default as ServiceManagementPage } from './ServiceManagementPage';
|
||||
export { default as ReviewManagementPage } from './ReviewManagementPage';
|
||||
export { default as PromotionManagementPage } from './PromotionManagementPage';
|
||||
export { default as CheckInPage } from './CheckInPage';
|
||||
export { default as CheckOutPage } from './CheckOutPage';
|
||||
328
Frontend/src/pages/auth/ForgotPasswordPage.tsx
Normal file
328
Frontend/src/pages/auth/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Mail,
|
||||
ArrowLeft,
|
||||
Send,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Hotel,
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
forgotPasswordSchema,
|
||||
ForgotPasswordFormData,
|
||||
} from '../../utils/validationSchemas';
|
||||
|
||||
const ForgotPasswordPage: React.FC = () => {
|
||||
const { forgotPassword, isLoading, error, clearError } =
|
||||
useAuthStore();
|
||||
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [submittedEmail, setSubmittedEmail] = useState('');
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ForgotPasswordFormData>({
|
||||
resolver: yupResolver(forgotPasswordSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (data: ForgotPasswordFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
setSubmittedEmail(data.email);
|
||||
await forgotPassword({ email: data.email });
|
||||
|
||||
// Show success state
|
||||
setIsSuccess(true);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Forgot password error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-br
|
||||
from-blue-50 to-indigo-100 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-blue-600 rounded-full">
|
||||
<Hotel className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Forgot Password?
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Enter your email to receive a password reset link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
{isSuccess ? (
|
||||
// Success State
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100
|
||||
rounded-full flex items-center
|
||||
justify-center"
|
||||
>
|
||||
<CheckCircle
|
||||
className="w-10 h-10 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3
|
||||
className="text-xl font-semibold
|
||||
text-gray-900"
|
||||
>
|
||||
Email Sent!
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
We have sent a password reset link to
|
||||
</p>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{submittedEmail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-4 text-left"
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
<strong>Note:</strong>
|
||||
</p>
|
||||
<ul
|
||||
className="mt-2 space-y-1 text-sm
|
||||
text-gray-600 list-disc list-inside"
|
||||
>
|
||||
<li>Link is valid for 1 hour</li>
|
||||
<li>Check your Spam/Junk folder</li>
|
||||
<li>
|
||||
If you don't receive the email, please try again
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSuccess(false);
|
||||
clearError();
|
||||
}}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
border-gray-300 rounded-lg
|
||||
text-sm font-medium text-gray-700
|
||||
bg-white hover:bg-gray-50
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-blue-500
|
||||
transition-colors"
|
||||
>
|
||||
<Mail className="-ml-1 mr-2 h-5 w-5" />
|
||||
Resend Email
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to="/login"
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
border-transparent rounded-lg
|
||||
text-sm font-medium text-white
|
||||
bg-blue-600 hover:bg-blue-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-blue-500
|
||||
transition-colors"
|
||||
>
|
||||
<ArrowLeft
|
||||
className="-ml-1 mr-2 h-5 w-5"
|
||||
/>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Form State
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-50 border
|
||||
border-red-200 text-red-700
|
||||
px-4 py-3 rounded-lg text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Mail
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
{...register('email')}
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
className={`block w-full pl-10 pr-3
|
||||
py-3 border rounded-lg
|
||||
focus:outline-none focus:ring-2
|
||||
transition-colors
|
||||
${
|
||||
errors.email
|
||||
? 'border-red-300 ' +
|
||||
'focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-blue-500'
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
border-transparent rounded-lg
|
||||
shadow-sm text-sm font-medium
|
||||
text-white bg-blue-600
|
||||
hover:bg-blue-700 focus:outline-none
|
||||
focus:ring-2 focus:ring-offset-2
|
||||
focus:ring-blue-500
|
||||
disabled:opacity-50
|
||||
disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5"
|
||||
/>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="-ml-1 mr-2 h-5 w-5" />
|
||||
Send Reset Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Back to Login Link */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center
|
||||
text-sm font-medium text-blue-600
|
||||
hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<ArrowLeft
|
||||
className="mr-1 h-4 w-4"
|
||||
/>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
{!isSuccess && (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600
|
||||
hover:underline"
|
||||
>
|
||||
Register now
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
border border-gray-200 p-4"
|
||||
>
|
||||
<h3
|
||||
className="text-sm font-semibold text-gray-900
|
||||
mb-2"
|
||||
>
|
||||
Need Help?
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600">
|
||||
If you're having trouble resetting your password,
|
||||
please contact our support team via email{' '}
|
||||
<a
|
||||
href="mailto:support@hotel.com"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
support@hotel.com
|
||||
</a>{' '}
|
||||
or hotline{' '}
|
||||
<a
|
||||
href="tel:1900-xxxx"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
1900-xxxx
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
287
Frontend/src/pages/auth/LoginPage.tsx
Normal file
287
Frontend/src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
LogIn,
|
||||
Loader2,
|
||||
Mail,
|
||||
Lock,
|
||||
Hotel
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
loginSchema,
|
||||
LoginFormData
|
||||
} from '../../utils/validationSchemas';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, isLoading, error, clearError } =
|
||||
useAuthStore();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: yupResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
await login({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
rememberMe: data.rememberMe,
|
||||
});
|
||||
|
||||
// Redirect to previous page or dashboard
|
||||
const from = location.state?.from?.pathname ||
|
||||
'/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Login error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br
|
||||
from-blue-50 to-indigo-100 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-blue-600 rounded-full">
|
||||
<Hotel className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Login
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Welcome back to Hotel Booking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<form onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-lg
|
||||
text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('email')}
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={`block w-full pl-10 pr-3 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${errors.email
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('password')}
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className={`block w-full pl-10 pr-10 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${errors.password
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5
|
||||
text-gray-400 hover:text-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
{...register('rememberMe')}
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600
|
||||
focus:ring-blue-500 border-gray-300
|
||||
rounded cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="ml-2 block text-sm
|
||||
text-gray-700 cursor-pointer"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm font-medium
|
||||
text-blue-600 hover:text-blue-500
|
||||
transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
border-transparent rounded-lg shadow-sm
|
||||
text-sm font-medium text-white
|
||||
bg-blue-600 hover:bg-blue-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2 focus:ring-blue-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5"
|
||||
/>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="-ml-1 mr-2 h-5 w-5" />
|
||||
Login
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600
|
||||
hover:text-blue-500 transition-colors"
|
||||
>
|
||||
Register now
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
By logging in, you agree to our{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
517
Frontend/src/pages/auth/RegisterPage.tsx
Normal file
517
Frontend/src/pages/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
UserPlus,
|
||||
Loader2,
|
||||
Mail,
|
||||
Lock,
|
||||
User,
|
||||
Phone,
|
||||
Hotel,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
registerSchema,
|
||||
RegisterFormData,
|
||||
} from '../../utils/validationSchemas';
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register: registerUser, isLoading, error, clearError } =
|
||||
useAuthStore();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState(false);
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: yupResolver(registerSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
phone: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Watch password to display password strength
|
||||
const password = watch('password');
|
||||
|
||||
// Password strength checker
|
||||
const getPasswordStrength = (pwd: string) => {
|
||||
if (!pwd) return { strength: 0, label: '', color: '' };
|
||||
|
||||
let strength = 0;
|
||||
if (pwd.length >= 8) strength++;
|
||||
if (/[a-z]/.test(pwd)) strength++;
|
||||
if (/[A-Z]/.test(pwd)) strength++;
|
||||
if (/\d/.test(pwd)) strength++;
|
||||
if (/[@$!%*?&]/.test(pwd)) strength++;
|
||||
|
||||
const labels = [
|
||||
{ label: 'Very Weak', color: 'bg-red-500' },
|
||||
{ label: 'Weak', color: 'bg-orange-500' },
|
||||
{ label: 'Medium', color: 'bg-yellow-500' },
|
||||
{ label: 'Strong', color: 'bg-blue-500' },
|
||||
{ label: 'Very Strong', color: 'bg-green-500' },
|
||||
];
|
||||
|
||||
return { strength, ...labels[strength] };
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(password || '');
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
await registerUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
phone: data.phone,
|
||||
});
|
||||
|
||||
// Redirect to login page
|
||||
navigate('/login', { replace: true });
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Register error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-br
|
||||
from-purple-50 to-pink-100 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-purple-600 rounded-full">
|
||||
<Hotel className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Create Account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Create a new account to book hotel rooms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-5"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-lg
|
||||
text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
className={`block w-full pl-10 pr-3 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.name
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('email')}
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={`block w-full pl-10 pr-3 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('phone')}
|
||||
id="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
className={`block w-full pl-10 pr-3 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.phone
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
placeholder="0123456789"
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('password')}
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
className={`block w-full pl-10 pr-10 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{password && password.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-gray-200
|
||||
rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={`h-full transition-all
|
||||
duration-300 ${passwordStrength.color}`}
|
||||
style={{
|
||||
width: `${
|
||||
(passwordStrength.strength / 5) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium
|
||||
text-gray-600"
|
||||
>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<PasswordRequirement
|
||||
met={password.length >= 8}
|
||||
text="At least 8 characters"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={/[a-z]/.test(password)}
|
||||
text="Lowercase letter (a-z)"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={/[A-Z]/.test(password)}
|
||||
text="Uppercase letter (A-Z)"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={/\d/.test(password)}
|
||||
text="Number (0-9)"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={/[@$!%*?&]/.test(password)}
|
||||
text="Special character (@$!%*?&)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('confirmPassword')}
|
||||
id="confirmPassword"
|
||||
type={
|
||||
showConfirmPassword ? 'text' : 'password'
|
||||
}
|
||||
autoComplete="new-password"
|
||||
className={`block w-full pl-10 pr-10 py-3
|
||||
border rounded-lg focus:outline-none
|
||||
focus:ring-2 transition-colors
|
||||
${
|
||||
errors.confirmPassword
|
||||
? 'border-red-300 focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-purple-500'
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
border-transparent rounded-lg shadow-sm
|
||||
text-sm font-medium text-white
|
||||
bg-purple-600 hover:bg-purple-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2 focus:ring-purple-500
|
||||
disabled:opacity-50
|
||||
disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5"
|
||||
/>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Register
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-purple-600
|
||||
hover:text-purple-500 transition-colors"
|
||||
>
|
||||
Login now
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
By registering, you agree to our{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-purple-600 hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-purple-600 hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper component for password requirements
|
||||
const PasswordRequirement: React.FC<{
|
||||
met: boolean;
|
||||
text: string;
|
||||
}> = ({ met, text }) => (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-gray-300" />
|
||||
)}
|
||||
<span className={met ? 'text-green-600' : 'text-gray-500'}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RegisterPage;
|
||||
531
Frontend/src/pages/auth/ResetPasswordPage.tsx
Normal file
531
Frontend/src/pages/auth/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
KeyRound,
|
||||
Hotel,
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
resetPasswordSchema,
|
||||
ResetPasswordFormData,
|
||||
} from '../../utils/validationSchemas';
|
||||
|
||||
const ResetPasswordPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { resetPassword, isLoading, error, clearError } =
|
||||
useAuthStore();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<ResetPasswordFormData>({
|
||||
resolver: yupResolver(resetPasswordSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Watch password to display password strength
|
||||
const password = watch('password');
|
||||
|
||||
// Check if token exists
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/forgot-password', { replace: true });
|
||||
}
|
||||
}, [token, navigate]);
|
||||
|
||||
// Password strength checker
|
||||
const getPasswordStrength = (pwd: string) => {
|
||||
if (!pwd) return { strength: 0, label: '', color: '' };
|
||||
|
||||
let strength = 0;
|
||||
if (pwd.length >= 8) strength++;
|
||||
if (/[a-z]/.test(pwd)) strength++;
|
||||
if (/[A-Z]/.test(pwd)) strength++;
|
||||
if (/\d/.test(pwd)) strength++;
|
||||
if (/[@$!%*?&]/.test(pwd)) strength++;
|
||||
|
||||
const labels = [
|
||||
{ label: 'Very Weak', color: 'bg-red-500' },
|
||||
{ label: 'Weak', color: 'bg-orange-500' },
|
||||
{ label: 'Medium', color: 'bg-yellow-500' },
|
||||
{ label: 'Strong', color: 'bg-blue-500' },
|
||||
{ label: 'Very Strong', color: 'bg-green-500' },
|
||||
];
|
||||
|
||||
return { strength, ...labels[strength] };
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(password || '');
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (data: ResetPasswordFormData) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
clearError();
|
||||
await resetPassword({
|
||||
token,
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
});
|
||||
|
||||
// Show success state
|
||||
setIsSuccess(true);
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/login', { replace: true });
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Reset password error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Invalid token error check
|
||||
const isTokenError =
|
||||
error?.includes('token') || error?.includes('expired');
|
||||
|
||||
// New password reuse error check
|
||||
const isReuseError =
|
||||
error?.toLowerCase().includes('must be different') ||
|
||||
error?.toLowerCase().includes('different from old');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-br
|
||||
from-indigo-50 to-purple-100 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-indigo-600 rounded-full">
|
||||
<Hotel className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
{isSuccess ? 'Complete!' : 'Reset Password'}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{isSuccess
|
||||
? 'Password has been reset successfully'
|
||||
: 'Enter a new password for your account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
{isSuccess ? (
|
||||
// Success State
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100
|
||||
rounded-full flex items-center
|
||||
justify-center"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="w-10 h-10 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3
|
||||
className="text-xl font-semibold
|
||||
text-gray-900"
|
||||
>
|
||||
Password reset successful!
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Your password has been updated.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
You can now login with your new password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-4"
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
Redirecting to login page...
|
||||
</p>
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Loader2
|
||||
className="animate-spin h-5 w-5
|
||||
text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center
|
||||
justify-center w-full py-3 px-4
|
||||
border border-transparent rounded-lg
|
||||
text-sm font-medium text-white
|
||||
bg-indigo-600 hover:bg-indigo-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-indigo-500
|
||||
transition-colors"
|
||||
>
|
||||
<KeyRound className="-ml-1 mr-2 h-5 w-5" />
|
||||
Login Now
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
// Form State
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-5"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className={`border px-4 py-3 rounded-lg
|
||||
text-sm flex items-start gap-2
|
||||
${
|
||||
isTokenError
|
||||
? 'bg-yellow-50 border-yellow-200 ' +
|
||||
'text-yellow-800'
|
||||
: 'bg-red-50 border-red-200 ' +
|
||||
'text-red-700'
|
||||
}`}
|
||||
>
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{isReuseError
|
||||
? 'New password must be different from old password'
|
||||
: error}
|
||||
</p>
|
||||
{isTokenError && (
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="mt-2 inline-block text-sm
|
||||
font-medium underline
|
||||
hover:text-yellow-900"
|
||||
>
|
||||
Request new link
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Lock
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
{...register('password')}
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
className={`block w-full pl-10 pr-10
|
||||
py-3 border rounded-lg
|
||||
focus:outline-none focus:ring-2
|
||||
transition-colors
|
||||
${
|
||||
errors.password
|
||||
? 'border-red-300 ' +
|
||||
'focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-indigo-500'
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowPassword(!showPassword)
|
||||
}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{password && password.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex-1 h-2 bg-gray-200
|
||||
rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={`h-full transition-all
|
||||
duration-300
|
||||
${passwordStrength.color}`}
|
||||
style={{
|
||||
width: `${
|
||||
(passwordStrength.strength / 5) *
|
||||
100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium
|
||||
text-gray-600"
|
||||
>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<PasswordRequirement
|
||||
met={password.length >= 8}
|
||||
text="At least 8 characters"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={/[a-z]/.test(password)}
|
||||
text="Lowercase letter (a-z)"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={/[A-Z]/.test(password)}
|
||||
text="Uppercase letter (A-Z)"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={/\d/.test(password)}
|
||||
text="Number (0-9)"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={/[@$!%*?&]/.test(password)}
|
||||
text="Special character (@$!%*?&)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Lock
|
||||
className="h-5 w-5 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
{...register('confirmPassword')}
|
||||
id="confirmPassword"
|
||||
type={
|
||||
showConfirmPassword ? 'text' : 'password'
|
||||
}
|
||||
autoComplete="new-password"
|
||||
className={`block w-full pl-10 pr-10
|
||||
py-3 border rounded-lg
|
||||
focus:outline-none focus:ring-2
|
||||
transition-colors
|
||||
${
|
||||
errors.confirmPassword
|
||||
? 'border-red-300 ' +
|
||||
'focus:ring-red-500'
|
||||
: 'border-gray-300 ' +
|
||||
'focus:ring-indigo-500'
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(
|
||||
!showConfirmPassword
|
||||
)
|
||||
}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
border-transparent rounded-lg
|
||||
shadow-sm text-sm font-medium
|
||||
text-white bg-indigo-600
|
||||
hover:bg-indigo-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-indigo-500
|
||||
disabled:opacity-50
|
||||
disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="animate-spin -ml-1 mr-2
|
||||
h-5 w-5"
|
||||
/>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound
|
||||
className="-ml-1 mr-2 h-5 w-5"
|
||||
/>
|
||||
Reset Password
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Back to Login Link */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm font-medium
|
||||
text-indigo-600 hover:text-indigo-500
|
||||
transition-colors"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Info */}
|
||||
{!isSuccess && (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
border border-gray-200 p-4"
|
||||
>
|
||||
<h3
|
||||
className="text-sm font-semibold
|
||||
text-gray-900 mb-2 flex items-center
|
||||
gap-2"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
Security
|
||||
</h3>
|
||||
<ul
|
||||
className="text-xs text-gray-600 space-y-1
|
||||
list-disc list-inside"
|
||||
>
|
||||
<li>Reset link is valid for 1 hour only</li>
|
||||
<li>Password is securely encrypted</li>
|
||||
<li>
|
||||
If the link expires, please request a new link
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper component for password requirements
|
||||
const PasswordRequirement: React.FC<{
|
||||
met: boolean;
|
||||
text: string;
|
||||
}> = ({ met, text }) => (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-gray-300" />
|
||||
)}
|
||||
<span className={met ? 'text-green-600' : 'text-gray-500'}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ResetPasswordPage;
|
||||
4
Frontend/src/pages/auth/index.ts
Normal file
4
Frontend/src/pages/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { default as RegisterPage } from './RegisterPage';
|
||||
export { default as ForgotPasswordPage } from './ForgotPasswordPage';
|
||||
export { default as ResetPasswordPage } from './ResetPasswordPage';
|
||||
643
Frontend/src/pages/customer/BookingDetailPage.tsx
Normal file
643
Frontend/src/pages/customer/BookingDetailPage.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
useNavigate,
|
||||
Link
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Users,
|
||||
CreditCard,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
FileText,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
DoorOpen,
|
||||
DoorClosed,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
getBookingById,
|
||||
cancelBooking,
|
||||
type Booking,
|
||||
} from '../../services/api/bookingService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import PaymentStatusBadge from
|
||||
'../../components/common/PaymentStatusBadge';
|
||||
|
||||
const BookingDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(
|
||||
null
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error(
|
||||
'Please login to view booking details'
|
||||
);
|
||||
navigate('/login', {
|
||||
state: { from: `/bookings/${id}` }
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, navigate, id]);
|
||||
|
||||
// Fetch booking details
|
||||
useEffect(() => {
|
||||
if (id && isAuthenticated) {
|
||||
fetchBookingDetails(Number(id));
|
||||
}
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
const fetchBookingDetails = async (bookingId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await getBookingById(bookingId);
|
||||
|
||||
if (
|
||||
response.success &&
|
||||
response.data?.booking
|
||||
) {
|
||||
setBooking(response.data.booking);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unable to load booking information'
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching booking:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load booking information';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelBooking = async () => {
|
||||
if (!booking) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to cancel booking ` +
|
||||
`${booking.booking_number}?\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 {
|
||||
setCancelling(true);
|
||||
|
||||
const response = await cancelBooking(booking.id);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
`✅ Booking ${booking.booking_number} cancelled successfully!`
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setBooking((prev) =>
|
||||
prev
|
||||
? { ...prev, status: 'cancelled' }
|
||||
: null
|
||||
);
|
||||
} 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 {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(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) => {
|
||||
return (
|
||||
booking.status === 'pending' ||
|
||||
booking.status === 'confirmed'
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading..." />;
|
||||
}
|
||||
|
||||
if (error || !booking) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
{error || 'Booking not found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/bookings')}
|
||||
className="px-6 py-2 bg-red-600
|
||||
text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Back to list
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const room = booking.room;
|
||||
const roomType = room?.room_type;
|
||||
const statusConfig = getStatusConfig(booking.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="inline-flex items-center gap-2
|
||||
text-gray-600 hover:text-gray-900
|
||||
mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to list</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Title */}
|
||||
<div className="flex items-center justify-between
|
||||
mb-6"
|
||||
>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Booking Details
|
||||
</h1>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4
|
||||
py-2 rounded-full font-medium
|
||||
${statusConfig.color}`}
|
||||
>
|
||||
<StatusIcon className="w-5 h-5" />
|
||||
{statusConfig.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Number */}
|
||||
<div className="bg-indigo-50 border
|
||||
border-indigo-200 rounded-lg p-4 mb-6"
|
||||
>
|
||||
<p className="text-sm text-indigo-600
|
||||
font-medium mb-1"
|
||||
>
|
||||
Booking Number
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-indigo-900
|
||||
font-mono"
|
||||
>
|
||||
{booking.booking_number}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Room Information */}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900
|
||||
mb-4"
|
||||
>
|
||||
Room Information
|
||||
</h2>
|
||||
|
||||
{roomType && (
|
||||
<div className="flex flex-col md:flex-row
|
||||
gap-6"
|
||||
>
|
||||
{/* Room Image */}
|
||||
{roomType.images?.[0] && (
|
||||
<div className="md:w-64 flex-shrink-0">
|
||||
<img
|
||||
src={roomType.images[0]}
|
||||
alt={roomType.name}
|
||||
className="w-full h-48 md:h-full
|
||||
object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Details */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold
|
||||
text-gray-900 mb-2"
|
||||
>
|
||||
{roomType.name}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<MapPin className="w-4 h-4 inline mr-1" />
|
||||
Room {room?.room_number} -
|
||||
Floor {room?.floor}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Capacity
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
Max {roomType.capacity} guests
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Room Price
|
||||
</p>
|
||||
<p className="font-medium text-indigo-600">
|
||||
{formatPrice(roomType.base_price)}/night
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Details */}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900
|
||||
mb-4"
|
||||
>
|
||||
Booking Details
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
gap-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<Calendar className="w-4 h-4 inline mr-1" />
|
||||
Check-in Date
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(booking.check_in_date)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<Calendar className="w-4 h-4 inline mr-1" />
|
||||
Check-out Date
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(booking.check_out_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Count */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<Users className="w-4 h-4 inline mr-1" />
|
||||
Number of Guests
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.guest_count} guest(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{booking.notes && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<FileText className="w-4 h-4 inline mr-1" />
|
||||
Notes
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<CreditCard className="w-4 h-4 inline mr-1" />
|
||||
Payment Method
|
||||
</p>
|
||||
<p className="font-medium text-gray-900 mb-2">
|
||||
{booking.payment_method === 'cash'
|
||||
? '💵 Pay at hotel'
|
||||
: '🏦 Bank transfer'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
Status:
|
||||
</span>
|
||||
<PaymentStatusBadge
|
||||
status={booking.payment_status}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Price */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between
|
||||
items-center"
|
||||
>
|
||||
<span className="text-lg font-semibold
|
||||
text-gray-900"
|
||||
>
|
||||
Total Payment
|
||||
</span>
|
||||
<span className="text-2xl font-bold
|
||||
text-indigo-600"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Information */}
|
||||
{booking.guest_info && (
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900
|
||||
mb-4"
|
||||
>
|
||||
Customer Information
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
<User className="w-4 h-4 inline mr-1" />
|
||||
Full Name
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.guest_info.full_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
<Mail className="w-4 h-4 inline mr-1" />
|
||||
Email
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.guest_info.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
<Phone className="w-4 h-4 inline mr-1" />
|
||||
Phone Number
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.guest_info.phone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bank Transfer Info */}
|
||||
{booking.payment_method === 'bank_transfer' &&
|
||||
booking.payment_status === 'unpaid' && (
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-6 mb-6"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2
|
||||
className="w-6 h-6 text-blue-600
|
||||
mt-1 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900 mb-2">
|
||||
Bank Transfer Information
|
||||
</h3>
|
||||
<div className="bg-white rounded p-4
|
||||
space-y-2 text-sm"
|
||||
>
|
||||
<p>
|
||||
<strong>Bank:</strong>
|
||||
Vietcombank (VCB)
|
||||
</p>
|
||||
<p>
|
||||
<strong>Account Number:</strong>
|
||||
0123456789
|
||||
</p>
|
||||
<p>
|
||||
<strong>Account Holder:</strong>
|
||||
KHACH SAN ABC
|
||||
</p>
|
||||
<p>
|
||||
<strong>Amount:</strong>{' '}
|
||||
<span className="text-indigo-600
|
||||
font-bold"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Content:</strong>{' '}
|
||||
<span className="font-mono
|
||||
text-indigo-600"
|
||||
>
|
||||
{booking.booking_number}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Important Notes */}
|
||||
<div
|
||||
className="bg-yellow-50 border border-yellow-200
|
||||
rounded-lg p-4 mb-6"
|
||||
>
|
||||
<p className="text-sm text-yellow-800 font-medium
|
||||
mb-2"
|
||||
>
|
||||
⚠️ Important Notice
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 space-y-1
|
||||
ml-4 list-disc"
|
||||
>
|
||||
<li>
|
||||
Please bring your ID card when checking in
|
||||
</li>
|
||||
<li>
|
||||
Check-in time: 14:00 /
|
||||
Check-out time: 12:00
|
||||
</li>
|
||||
{canCancelBooking(booking) && (
|
||||
<li>
|
||||
If you cancel the booking, 20% of
|
||||
the total order value will be charged
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Payment Button for unpaid bank transfer */}
|
||||
{booking.payment_method === 'bank_transfer' &&
|
||||
booking.payment_status === 'unpaid' && (
|
||||
<Link
|
||||
to={`/payment/${booking.id}`}
|
||||
className="flex-1 flex items-center
|
||||
justify-center gap-2 px-6 py-3
|
||||
bg-green-600 text-white rounded-lg
|
||||
hover:bg-green-700 transition-colors
|
||||
font-semibold"
|
||||
>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Confirm Payment
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{canCancelBooking(booking) && (
|
||||
<button
|
||||
onClick={handleCancelBooking}
|
||||
disabled={cancelling}
|
||||
className="flex-1 flex items-center
|
||||
justify-center gap-2 px-6 py-3
|
||||
bg-red-600 text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors
|
||||
font-semibold disabled:bg-gray-400
|
||||
disabled:cursor-not-allowed"
|
||||
>
|
||||
{cancelling ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="w-5 h-5 animate-spin"
|
||||
/>
|
||||
Cancelling...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-5 h-5" />
|
||||
Cancel Booking
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="flex-1 flex items-center
|
||||
justify-center gap-2 px-6 py-3
|
||||
bg-gray-600 text-white rounded-lg
|
||||
hover:bg-gray-700 transition-colors
|
||||
font-semibold"
|
||||
>
|
||||
Back to list
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingDetailPage;
|
||||
125
Frontend/src/pages/customer/BookingListPage.tsx
Normal file
125
Frontend/src/pages/customer/BookingListPage.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Calendar, Clock, DollarSign } from 'lucide-react';
|
||||
|
||||
const BookingListPage: React.FC = () => {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
Booking History
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Manage and track your bookings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Booking List */}
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((booking) => (
|
||||
<div key={booking}
|
||||
className="bg-white rounded-lg shadow-md
|
||||
p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row
|
||||
md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center
|
||||
space-x-3 mb-3"
|
||||
>
|
||||
<h3 className="text-xl font-semibold
|
||||
text-gray-800"
|
||||
>
|
||||
Room {booking}01 - Deluxe
|
||||
</h3>
|
||||
<span className="px-3 py-1
|
||||
bg-green-100 text-green-800
|
||||
rounded-full text-sm font-medium"
|
||||
>
|
||||
Confirmed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-3 gap-4 text-sm
|
||||
text-gray-600"
|
||||
>
|
||||
<div className="flex items-center
|
||||
space-x-2"
|
||||
>
|
||||
<Calendar className="w-4 h-4
|
||||
text-blue-500"
|
||||
/>
|
||||
<span>
|
||||
Check-in: 15/11/2025
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center
|
||||
space-x-2"
|
||||
>
|
||||
<Calendar className="w-4 h-4
|
||||
text-blue-500"
|
||||
/>
|
||||
<span>
|
||||
Check-out: 18/11/2025
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center
|
||||
space-x-2"
|
||||
>
|
||||
<Clock className="w-4 h-4
|
||||
text-blue-500"
|
||||
/>
|
||||
<span>3 nights</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 md:mt-0
|
||||
md:ml-6 flex flex-col items-end
|
||||
space-y-3"
|
||||
>
|
||||
<div className="flex items-center
|
||||
space-x-2"
|
||||
>
|
||||
<DollarSign className="w-5 h-5
|
||||
text-green-600"
|
||||
/>
|
||||
<span className="text-2xl font-bold
|
||||
text-gray-800"
|
||||
>
|
||||
${booking * 150}
|
||||
</span>
|
||||
</div>
|
||||
<button className="px-4 py-2
|
||||
bg-blue-600 text-white rounded-lg
|
||||
hover:bg-blue-700 transition-colors
|
||||
text-sm"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{/* Uncomment when there are no bookings
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">
|
||||
You don't have any bookings yet
|
||||
</p>
|
||||
<button className="mt-4 px-6 py-3
|
||||
bg-blue-600 text-white rounded-lg
|
||||
hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Book Now
|
||||
</button>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListPage;
|
||||
839
Frontend/src/pages/customer/BookingPage.tsx
Normal file
839
Frontend/src/pages/customer/BookingPage.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
useNavigate,
|
||||
Link
|
||||
} from 'react-router-dom';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import {
|
||||
Calendar,
|
||||
Users,
|
||||
CreditCard,
|
||||
Building2,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getRoomById, type Room } from
|
||||
'../../services/api/roomService';
|
||||
import {
|
||||
createBooking,
|
||||
checkRoomAvailability,
|
||||
type BookingData,
|
||||
} from '../../services/api/bookingService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
bookingValidationSchema,
|
||||
type BookingFormData
|
||||
} from '../../validators/bookingValidator';
|
||||
import Loading from '../../components/common/Loading';
|
||||
|
||||
const BookingPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, userInfo } = useAuthStore();
|
||||
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error(
|
||||
'Please login to make a booking'
|
||||
);
|
||||
navigate('/login', {
|
||||
state: { from: `/booking/${id}` }
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, navigate, id]);
|
||||
|
||||
// Fetch room details
|
||||
useEffect(() => {
|
||||
if (id && isAuthenticated) {
|
||||
fetchRoomDetails(Number(id));
|
||||
}
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
const fetchRoomDetails = async (roomId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getRoomById(roomId);
|
||||
|
||||
if (
|
||||
(response.success ||
|
||||
(response as any).status === 'success') &&
|
||||
response.data?.room
|
||||
) {
|
||||
setRoom(response.data.room);
|
||||
} else {
|
||||
throw new Error('Unable to load room information');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching room:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load room information';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up form with default values
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<BookingFormData>({
|
||||
resolver: yupResolver(bookingValidationSchema),
|
||||
defaultValues: {
|
||||
checkInDate: undefined,
|
||||
checkOutDate: undefined,
|
||||
guestCount: 1,
|
||||
notes: '',
|
||||
paymentMethod: 'cash',
|
||||
fullName: userInfo?.name || '',
|
||||
email: userInfo?.email || '',
|
||||
phone: userInfo?.phone || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Watch form values for calculations
|
||||
const checkInDate = watch('checkInDate');
|
||||
const checkOutDate = watch('checkOutDate');
|
||||
const paymentMethod = watch('paymentMethod');
|
||||
|
||||
// Calculate number of nights and total price
|
||||
const numberOfNights =
|
||||
checkInDate && checkOutDate
|
||||
? Math.ceil(
|
||||
(checkOutDate.getTime() -
|
||||
checkInDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
)
|
||||
: 0;
|
||||
|
||||
const roomPrice =
|
||||
room?.room_type?.base_price || 0;
|
||||
const totalPrice = numberOfNights * roomPrice;
|
||||
|
||||
// Format price
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (data: BookingFormData) => {
|
||||
if (!room) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
const checkInDateStr = data.checkInDate
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const checkOutDateStr = data.checkOutDate
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
// Step 1: Check room availability
|
||||
const availability = await checkRoomAvailability(
|
||||
room.id,
|
||||
checkInDateStr,
|
||||
checkOutDateStr
|
||||
);
|
||||
|
||||
if (!availability.available) {
|
||||
toast.error(
|
||||
availability.message ||
|
||||
'Room is already booked during this time'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Prepare booking data
|
||||
const bookingData: BookingData = {
|
||||
room_id: room.id,
|
||||
check_in_date: checkInDateStr,
|
||||
check_out_date: checkOutDateStr,
|
||||
guest_count: data.guestCount,
|
||||
notes: data.notes || '',
|
||||
payment_method: data.paymentMethod,
|
||||
total_price: totalPrice,
|
||||
guest_info: {
|
||||
full_name: data.fullName,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
},
|
||||
};
|
||||
|
||||
// Step 3: Create booking
|
||||
const response = await createBooking(bookingData);
|
||||
|
||||
if (
|
||||
response.success &&
|
||||
response.data?.booking
|
||||
) {
|
||||
const bookingId = response.data.booking.id;
|
||||
|
||||
toast.success(
|
||||
'🎉 Booking successful!',
|
||||
{ icon: <CheckCircle className="text-green-500" /> }
|
||||
);
|
||||
|
||||
// Navigate to success page
|
||||
navigate(`/booking-success/${bookingId}`);
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message ||
|
||||
'Unable to create booking'
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error creating booking:', err);
|
||||
|
||||
// Handle specific error cases
|
||||
if (err.response?.status === 409) {
|
||||
toast.error(
|
||||
'❌ Room is already booked during this time. ' +
|
||||
'Please select different dates.'
|
||||
);
|
||||
} else if (err.response?.status === 400) {
|
||||
toast.error(
|
||||
err.response?.data?.message ||
|
||||
'Invalid booking information'
|
||||
);
|
||||
} else {
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to book room. Please try again.';
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading..." />;
|
||||
}
|
||||
|
||||
if (error || !room) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
{error || 'Room not found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/rooms')}
|
||||
className="inline-flex items-center gap-2 bg-indigo-600
|
||||
text-white px-3 py-2 rounded-md hover:bg-indigo-700
|
||||
disabled:bg-gray-400 mb-6 transition-colors"
|
||||
>
|
||||
Back to Room List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roomType = room.room_type;
|
||||
if (!roomType) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to={`/rooms/${room.id}`}
|
||||
className="inline-flex items-center gap-2
|
||||
text-gray-600 hover:text-gray-900
|
||||
mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to room details</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Title */}
|
||||
<h1
|
||||
className="text-3xl font-bold text-gray-900 mb-8"
|
||||
>
|
||||
Book Room
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Booking Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="bg-white rounded-lg shadow-md
|
||||
p-6 space-y-6"
|
||||
>
|
||||
{/* Guest Information */}
|
||||
<div>
|
||||
<h2
|
||||
className="text-xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
Customer Information
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-1"
|
||||
>
|
||||
Full Name
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('fullName')}
|
||||
type="text"
|
||||
className="w-full px-4 py-2 border
|
||||
border-gray-300 rounded-lg
|
||||
focus:ring-2 focus:ring-indigo-500
|
||||
focus:border-indigo-500"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
{errors.fullName && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{errors.fullName.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email & Phone */}
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm
|
||||
font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Email
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
className="w-full px-4 py-2 border
|
||||
border-gray-300 rounded-lg
|
||||
focus:ring-2
|
||||
focus:ring-indigo-500
|
||||
focus:border-indigo-500"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-600
|
||||
mt-1"
|
||||
>
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm
|
||||
font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Phone Number
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('phone')}
|
||||
type="tel"
|
||||
className="w-full px-4 py-2 border
|
||||
border-gray-300 rounded-lg
|
||||
focus:ring-2
|
||||
focus:ring-indigo-500
|
||||
focus:border-indigo-500"
|
||||
placeholder="0123456789"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-red-600
|
||||
mt-1"
|
||||
>
|
||||
{errors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Details */}
|
||||
<div className="border-t pt-6">
|
||||
<h2
|
||||
className="text-xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
Booking Details
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Date Range */}
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-2 gap-4"
|
||||
>
|
||||
{/* Check-in Date */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm
|
||||
font-medium text-gray-700 mb-1"
|
||||
>
|
||||
<Calendar
|
||||
className="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Check-in Date
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="checkInDate"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
selected={field.value}
|
||||
onChange={(date) =>
|
||||
field.onChange(date)
|
||||
}
|
||||
minDate={new Date()}
|
||||
selectsStart
|
||||
startDate={checkInDate}
|
||||
endDate={checkOutDate}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
placeholderText="Select check-in date"
|
||||
className="w-full px-4 py-2
|
||||
border border-gray-300
|
||||
rounded-lg focus:ring-2
|
||||
focus:ring-indigo-500
|
||||
focus:border-indigo-500"
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.checkInDate && (
|
||||
<p className="text-sm text-red-600
|
||||
mt-1"
|
||||
>
|
||||
{errors.checkInDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check-out Date */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm
|
||||
font-medium text-gray-700 mb-1"
|
||||
>
|
||||
<Calendar
|
||||
className="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Check-out Date
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="checkOutDate"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
selected={field.value}
|
||||
onChange={(date) =>
|
||||
field.onChange(date)
|
||||
}
|
||||
minDate={
|
||||
checkInDate || new Date()
|
||||
}
|
||||
selectsEnd
|
||||
startDate={checkInDate}
|
||||
endDate={checkOutDate}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
placeholderText="Select check-out date"
|
||||
className="w-full px-4 py-2
|
||||
border border-gray-300
|
||||
rounded-lg focus:ring-2
|
||||
focus:ring-indigo-500
|
||||
focus:border-indigo-500"
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.checkOutDate && (
|
||||
<p className="text-sm text-red-600
|
||||
mt-1"
|
||||
>
|
||||
{errors.checkOutDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Count */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-1"
|
||||
>
|
||||
<Users
|
||||
className="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Number of Guests
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('guestCount')}
|
||||
type="number"
|
||||
min="1"
|
||||
max={roomType.capacity}
|
||||
className="w-full px-4 py-2 border
|
||||
border-gray-300 rounded-lg
|
||||
focus:ring-2 focus:ring-indigo-500
|
||||
focus:border-indigo-500"
|
||||
placeholder="1"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Maximum capacity: {roomType.capacity} guests
|
||||
</p>
|
||||
{errors.guestCount && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{errors.guestCount.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-1"
|
||||
>
|
||||
<FileText
|
||||
className="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border
|
||||
border-gray-300 rounded-lg
|
||||
focus:ring-2 focus:ring-indigo-500
|
||||
focus:border-indigo-500"
|
||||
placeholder="Special requests..."
|
||||
/>
|
||||
{errors.notes && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{errors.notes.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="border-t pt-6">
|
||||
<h2
|
||||
className="text-xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
Payment Method
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Cash */}
|
||||
<label
|
||||
className="flex items-start p-4
|
||||
border-2 border-gray-200
|
||||
rounded-lg cursor-pointer
|
||||
hover:border-indigo-500
|
||||
transition-colors"
|
||||
>
|
||||
<input
|
||||
{...register('paymentMethod')}
|
||||
type="radio"
|
||||
value="cash"
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center
|
||||
gap-2 mb-1"
|
||||
>
|
||||
<CreditCard
|
||||
className="w-5 h-5
|
||||
text-gray-600"
|
||||
/>
|
||||
<span className="font-medium
|
||||
text-gray-900"
|
||||
>
|
||||
Pay on arrival
|
||||
</span>
|
||||
<span className="text-xs bg-orange-100
|
||||
text-orange-700 px-2 py-0.5 rounded"
|
||||
>
|
||||
Requires 20% deposit
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Pay the remaining balance on arrival
|
||||
</p>
|
||||
<div className="bg-orange-50 border
|
||||
border-orange-200 rounded p-2"
|
||||
>
|
||||
<p className="text-xs text-orange-800">
|
||||
<strong>Note:</strong> You need to pay
|
||||
<strong> 20% deposit</strong> via
|
||||
bank transfer immediately after booking to
|
||||
secure the room. Pay the remaining balance
|
||||
on arrival.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Bank Transfer */}
|
||||
<label
|
||||
className="flex items-start p-4
|
||||
border-2 border-gray-200
|
||||
rounded-lg cursor-pointer
|
||||
hover:border-indigo-500
|
||||
transition-colors"
|
||||
>
|
||||
<input
|
||||
{...register('paymentMethod')}
|
||||
type="radio"
|
||||
value="bank_transfer"
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center
|
||||
gap-2 mb-1"
|
||||
>
|
||||
<Building2
|
||||
className="w-5 h-5
|
||||
text-gray-600"
|
||||
/>
|
||||
<span className="font-medium
|
||||
text-gray-900"
|
||||
>
|
||||
Bank Transfer
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Transfer via QR code or
|
||||
account number
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{errors.paymentMethod && (
|
||||
<p className="text-sm text-red-600">
|
||||
{errors.paymentMethod.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bank Transfer Info */}
|
||||
{paymentMethod === 'bank_transfer' && (
|
||||
<div
|
||||
className="bg-blue-50 border
|
||||
border-blue-200 rounded-lg
|
||||
p-4 mt-3"
|
||||
>
|
||||
<p className="text-sm text-blue-800
|
||||
font-medium mb-2"
|
||||
>
|
||||
📌 Bank Transfer Information
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
Scan QR code or transfer according to
|
||||
the information after confirming the booking.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="border-t pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full bg-indigo-600
|
||||
text-white py-4 rounded-lg
|
||||
hover:bg-indigo-700
|
||||
transition-colors font-semibold
|
||||
text-lg disabled:bg-gray-400
|
||||
disabled:cursor-not-allowed
|
||||
flex items-center justify-center
|
||||
gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="w-5 h-5 animate-spin"
|
||||
/>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Confirm Booking'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Booking Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md
|
||||
p-6 sticky top-8"
|
||||
>
|
||||
<h2
|
||||
className="text-xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
Booking Summary
|
||||
</h2>
|
||||
|
||||
{/* Room Info */}
|
||||
<div className="mb-4">
|
||||
{roomType.images?.[0] && (
|
||||
<img
|
||||
src={roomType.images[0]}
|
||||
alt={roomType.name}
|
||||
className="w-full h-48 object-cover
|
||||
rounded-lg mb-3"
|
||||
/>
|
||||
)}
|
||||
<h3 className="font-bold text-gray-900">
|
||||
{roomType.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Room {room.room_number} - Floor {room.floor}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing Breakdown */}
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<div className="flex justify-between
|
||||
text-sm"
|
||||
>
|
||||
<span className="text-gray-600">
|
||||
Room price/night
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(roomPrice)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{numberOfNights > 0 && (
|
||||
<div className="flex justify-between
|
||||
text-sm"
|
||||
>
|
||||
<span className="text-gray-600">
|
||||
Nights
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{numberOfNights} night(s)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="border-t pt-2 flex
|
||||
justify-between text-lg
|
||||
font-bold"
|
||||
>
|
||||
<span>Total</span>
|
||||
<span className="text-indigo-600">
|
||||
{numberOfNights > 0
|
||||
? formatPrice(totalPrice)
|
||||
: '---'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Deposit amount for cash payment */}
|
||||
{paymentMethod === 'cash' && numberOfNights > 0 && (
|
||||
<div className="bg-orange-50 border
|
||||
border-orange-200 rounded-lg p-3 mt-2"
|
||||
>
|
||||
<div className="flex justify-between
|
||||
items-center mb-1"
|
||||
>
|
||||
<span className="text-sm font-medium
|
||||
text-orange-900"
|
||||
>
|
||||
Deposit to pay (20%)
|
||||
</span>
|
||||
<span className="text-lg font-bold
|
||||
text-orange-700"
|
||||
>
|
||||
{formatPrice(totalPrice * 0.2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-orange-700">
|
||||
Pay via bank transfer to confirm booking
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div
|
||||
className={`border rounded-lg p-3 mt-4 ${
|
||||
paymentMethod === 'cash'
|
||||
? 'bg-orange-50 border-orange-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{paymentMethod === 'cash' ? (
|
||||
<p className="text-xs text-orange-800">
|
||||
🔒 <strong>Required:</strong> Pay 20% deposit
|
||||
via bank transfer after booking.
|
||||
Remaining balance ({formatPrice(totalPrice * 0.8)})
|
||||
to be paid on arrival.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-yellow-800">
|
||||
💡 Scan QR code or transfer according to the information
|
||||
after confirming the booking.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingPage;
|
||||
823
Frontend/src/pages/customer/BookingSuccessPage.tsx
Normal file
823
Frontend/src/pages/customer/BookingSuccessPage.tsx
Normal file
@@ -0,0 +1,823 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
useNavigate,
|
||||
Link
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
CheckCircle,
|
||||
Home,
|
||||
ListOrdered,
|
||||
Calendar,
|
||||
Users,
|
||||
CreditCard,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
User,
|
||||
FileText,
|
||||
Building2,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
getBookingById,
|
||||
generateQRCode,
|
||||
type Booking,
|
||||
} from '../../services/api/bookingService';
|
||||
import { confirmBankTransfer } from
|
||||
'../../services/api/paymentService';
|
||||
import Loading from '../../components/common/Loading';
|
||||
|
||||
const BookingSuccessPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(
|
||||
null
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedBookingNumber, setCopiedBookingNumber] =
|
||||
useState(false);
|
||||
const [uploadingReceipt, setUploadingReceipt] =
|
||||
useState(false);
|
||||
const [receiptUploaded, setReceiptUploaded] =
|
||||
useState(false);
|
||||
const [selectedFile, setSelectedFile] =
|
||||
useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] =
|
||||
useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchBookingDetails(Number(id));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchBookingDetails = async (bookingId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await getBookingById(bookingId);
|
||||
|
||||
if (
|
||||
response.success &&
|
||||
response.data?.booking
|
||||
) {
|
||||
const bookingData = response.data.booking;
|
||||
setBooking(bookingData);
|
||||
|
||||
// Redirect to deposit payment page if required and not yet paid
|
||||
if (
|
||||
bookingData.requires_deposit &&
|
||||
!bookingData.deposit_paid
|
||||
) {
|
||||
navigate(`/deposit-payment/${bookingId}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unable to load booking information'
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching booking:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load booking information';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'checked_in':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'checked_out':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'Confirmed';
|
||||
case 'pending':
|
||||
return 'Pending confirmation';
|
||||
case 'cancelled':
|
||||
return 'Cancelled';
|
||||
case 'checked_in':
|
||||
return 'Checked in';
|
||||
case 'checked_out':
|
||||
return 'Checked out';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const copyBookingNumber = async () => {
|
||||
if (!booking?.booking_number) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
booking.booking_number
|
||||
);
|
||||
setCopiedBookingNumber(true);
|
||||
toast.success('Booking number copied');
|
||||
setTimeout(() => setCopiedBookingNumber(false), 2000);
|
||||
} catch (err) {
|
||||
toast.error('Unable to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Image size must not exceed 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewUrl(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleUploadReceipt = async () => {
|
||||
if (!selectedFile || !booking) return;
|
||||
|
||||
try {
|
||||
setUploadingReceipt(true);
|
||||
|
||||
// Generate transaction ID based on booking number
|
||||
const transactionId =
|
||||
`TXN-${booking.booking_number}-${Date.now()}`;
|
||||
|
||||
const response = await confirmBankTransfer(
|
||||
booking.id,
|
||||
transactionId,
|
||||
selectedFile
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
'✅ Payment confirmation sent successfully! ' +
|
||||
'We will confirm as soon as possible.'
|
||||
);
|
||||
setReceiptUploaded(true);
|
||||
|
||||
// Update booking payment status locally
|
||||
setBooking((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
payment_status: 'paid',
|
||||
status: prev.status === 'pending'
|
||||
? 'confirmed'
|
||||
: prev.status
|
||||
}
|
||||
: null
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message ||
|
||||
'Unable to confirm payment'
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading receipt:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to send payment confirmation. ' +
|
||||
'Please try again.';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setUploadingReceipt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const qrCodeUrl = booking
|
||||
? generateQRCode(
|
||||
booking.booking_number,
|
||||
booking.total_price
|
||||
)
|
||||
: null;
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading..." />;
|
||||
}
|
||||
|
||||
if (error || !booking) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
{error || 'Booking not found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/rooms')}
|
||||
className="inline-flex items-center gap-2 bg-indigo-600
|
||||
text-white px-3 py-2 rounded-md hover:bg-indigo-700
|
||||
disabled:bg-gray-400 mb-6 transition-colors"
|
||||
>
|
||||
Back to room list
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const room = booking.room;
|
||||
const roomType = room?.room_type;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Success Header */}
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md
|
||||
p-8 mb-6 text-center"
|
||||
>
|
||||
<div
|
||||
className="w-20 h-20 bg-green-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<CheckCircle
|
||||
className="w-12 h-12 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
<h1
|
||||
className="text-3xl font-bold text-gray-900
|
||||
mb-2"
|
||||
>
|
||||
Booking Successful!
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Thank you for booking with our hotel
|
||||
</p>
|
||||
|
||||
{/* Booking Number */}
|
||||
<div
|
||||
className="inline-flex items-center gap-2
|
||||
bg-indigo-50 px-6 py-3 rounded-lg"
|
||||
>
|
||||
<span className="text-sm text-indigo-600
|
||||
font-medium"
|
||||
>
|
||||
Booking Number:
|
||||
</span>
|
||||
<span className="text-lg font-bold
|
||||
text-indigo-900"
|
||||
>
|
||||
{booking.booking_number}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyBookingNumber}
|
||||
className="ml-2 p-1 hover:bg-indigo-100
|
||||
rounded transition-colors"
|
||||
title="Copy booking number"
|
||||
>
|
||||
{copiedBookingNumber ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-indigo-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mt-4">
|
||||
<span
|
||||
className={`inline-block px-4 py-2
|
||||
rounded-full text-sm font-medium
|
||||
${getStatusColor(booking.status)}`}
|
||||
>
|
||||
{getStatusText(booking.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Details */}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900
|
||||
mb-4"
|
||||
>
|
||||
Booking Details
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Room Information */}
|
||||
{roomType && (
|
||||
<div className="border-b pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{roomType.images?.[0] && (
|
||||
<img
|
||||
src={roomType.images[0]}
|
||||
alt={roomType.name}
|
||||
className="w-24 h-24 object-cover
|
||||
rounded-lg"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-lg
|
||||
text-gray-900"
|
||||
>
|
||||
{roomType.name}
|
||||
</h3>
|
||||
{room && (
|
||||
<p className="text-gray-600 text-sm">
|
||||
<MapPin className="w-4 h-4
|
||||
inline mr-1"
|
||||
/>
|
||||
Room {room.room_number} -
|
||||
Floor {room.floor}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-indigo-600
|
||||
font-semibold mt-1"
|
||||
>
|
||||
{formatPrice(roomType.base_price)}/night
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
gap-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<Calendar className="w-4 h-4 inline mr-1" />
|
||||
Check-in Date
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(booking.check_in_date)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<Calendar className="w-4 h-4 inline mr-1" />
|
||||
Check-out Date
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(booking.check_out_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Count */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<Users className="w-4 h-4 inline mr-1" />
|
||||
Number of Guests
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.guest_count} guest(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{booking.notes && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<FileText className="w-4 h-4 inline mr-1" />
|
||||
Notes
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<CreditCard className="w-4 h-4 inline mr-1" />
|
||||
Payment Method
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.payment_method === 'cash'
|
||||
? '💵 Pay at hotel'
|
||||
: '🏦 Bank transfer'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total Price */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between
|
||||
items-center"
|
||||
>
|
||||
<span className="text-lg font-semibold
|
||||
text-gray-900"
|
||||
>
|
||||
Total Payment
|
||||
</span>
|
||||
<span className="text-2xl font-bold
|
||||
text-indigo-600"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Information */}
|
||||
{booking.guest_info && (
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900
|
||||
mb-4"
|
||||
>
|
||||
Customer Information
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
<User className="w-4 h-4 inline mr-1" />
|
||||
Full Name
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.guest_info.full_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
<Mail className="w-4 h-4 inline mr-1" />
|
||||
Email
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.guest_info.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
<Phone className="w-4 h-4 inline mr-1" />
|
||||
Phone Number
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.guest_info.phone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bank Transfer Instructions */}
|
||||
{booking.payment_method === 'bank_transfer' && (
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-6 mb-6"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Building2
|
||||
className="w-6 h-6 text-blue-600
|
||||
mt-1 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900 mb-2">
|
||||
Bank Transfer Instructions
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm
|
||||
text-blue-800"
|
||||
>
|
||||
<p>
|
||||
Please transfer according to the following information:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-2 gap-4"
|
||||
>
|
||||
{/* Bank Info */}
|
||||
<div className="bg-white rounded-lg
|
||||
p-4 space-y-2"
|
||||
>
|
||||
<p>
|
||||
<strong>Bank:</strong>
|
||||
Vietcombank (VCB)
|
||||
</p>
|
||||
<p>
|
||||
<strong>Account Number:</strong>
|
||||
0123456789
|
||||
</p>
|
||||
<p>
|
||||
<strong>Account Holder:</strong>
|
||||
KHACH SAN ABC
|
||||
</p>
|
||||
<p>
|
||||
<strong>Amount:</strong>{' '}
|
||||
<span className="text-indigo-600
|
||||
font-bold"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Content:</strong>{' '}
|
||||
<span className="font-mono
|
||||
text-indigo-600"
|
||||
>
|
||||
{booking.booking_number}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
{qrCodeUrl && (
|
||||
<div className="bg-white rounded-lg
|
||||
p-4 flex flex-col items-center
|
||||
justify-center"
|
||||
>
|
||||
<p className="text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Scan QR code to transfer
|
||||
</p>
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="QR Code"
|
||||
className="w-48 h-48 border-2
|
||||
border-gray-200 rounded-lg"
|
||||
/>
|
||||
<p className="text-xs text-gray-500
|
||||
mt-2 text-center"
|
||||
>
|
||||
QR code includes all information
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs italic mt-2">
|
||||
💡 Note: Please enter the correct booking number
|
||||
in the transfer content so we can confirm your payment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Receipt Section */}
|
||||
{!receiptUploaded ? (
|
||||
<div className="border-t border-blue-200
|
||||
pt-4"
|
||||
>
|
||||
<h4 className="font-semibold text-blue-900
|
||||
mb-3"
|
||||
>
|
||||
📎 Payment Confirmation
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700 mb-3">
|
||||
After transferring, please upload
|
||||
the receipt image so we can confirm faster.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* File Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="receipt-upload"
|
||||
className="block w-full px-4 py-3
|
||||
border-2 border-dashed
|
||||
border-blue-300 rounded-lg
|
||||
text-center cursor-pointer
|
||||
hover:border-blue-400
|
||||
hover:bg-blue-100/50
|
||||
transition-colors"
|
||||
>
|
||||
<input
|
||||
id="receipt-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex flex-col
|
||||
items-center gap-2"
|
||||
>
|
||||
{previewUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="w-32 h-32
|
||||
object-cover rounded"
|
||||
/>
|
||||
<p className="text-sm
|
||||
text-blue-600 font-medium"
|
||||
>
|
||||
{selectedFile?.name}
|
||||
</p>
|
||||
<p className="text-xs
|
||||
text-gray-500"
|
||||
>
|
||||
Click to select another image
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText
|
||||
className="w-8 h-8
|
||||
text-blue-400"
|
||||
/>
|
||||
<p className="text-sm
|
||||
text-blue-600 font-medium"
|
||||
>
|
||||
Select receipt image
|
||||
</p>
|
||||
<p className="text-xs
|
||||
text-gray-500"
|
||||
>
|
||||
PNG, JPG, JPEG (Max 5MB)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
{selectedFile && (
|
||||
<button
|
||||
onClick={handleUploadReceipt}
|
||||
disabled={uploadingReceipt}
|
||||
className="w-full px-4 py-3
|
||||
bg-blue-600 text-white
|
||||
rounded-lg hover:bg-blue-700
|
||||
transition-colors font-semibold
|
||||
disabled:bg-gray-400
|
||||
disabled:cursor-not-allowed
|
||||
flex items-center
|
||||
justify-center gap-2"
|
||||
>
|
||||
{uploadingReceipt ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="w-5 h-5
|
||||
animate-spin"
|
||||
/>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
Confirm payment completed
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-green-200
|
||||
pt-4 bg-green-50 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center
|
||||
gap-3"
|
||||
>
|
||||
<CheckCircle
|
||||
className="w-6 h-6 text-green-600
|
||||
flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold
|
||||
text-green-900"
|
||||
>
|
||||
Payment confirmation sent
|
||||
</p>
|
||||
<p className="text-sm text-green-700">
|
||||
We will confirm your order
|
||||
as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Important Notice */}
|
||||
<div
|
||||
className="bg-yellow-50 border border-yellow-200
|
||||
rounded-lg p-4 mb-6"
|
||||
>
|
||||
<p className="text-sm text-yellow-800">
|
||||
⚠️ <strong>Important Notice:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 mt-2
|
||||
space-y-1 ml-4 list-disc"
|
||||
>
|
||||
<li>
|
||||
Please bring your ID card when checking in
|
||||
</li>
|
||||
<li>
|
||||
Check-in time: 14:00 /
|
||||
Check-out time: 12:00
|
||||
</li>
|
||||
<li>
|
||||
If you cancel the booking, 20% of
|
||||
the total order value will be charged
|
||||
</li>
|
||||
{booking.payment_method === 'bank_transfer' && (
|
||||
<li>
|
||||
Please transfer within 24 hours
|
||||
to secure your room
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="flex-1 flex items-center
|
||||
justify-center gap-2 px-6 py-3
|
||||
bg-indigo-600 text-white rounded-lg
|
||||
hover:bg-indigo-700 transition-colors
|
||||
font-semibold"
|
||||
>
|
||||
<ListOrdered className="w-5 h-5" />
|
||||
View My Bookings
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex-1 flex items-center
|
||||
justify-center gap-2 px-6 py-3
|
||||
bg-gray-600 text-white rounded-lg
|
||||
hover:bg-gray-700 transition-colors
|
||||
font-semibold"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
Go to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingSuccessPage;
|
||||
250
Frontend/src/pages/customer/DashboardPage.tsx
Normal file
250
Frontend/src/pages/customer/DashboardPage.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
Hotel,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Overview of your activity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-4 gap-6 mb-8"
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Calendar className="w-6 h-6
|
||||
text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
+12%
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
Total Bookings
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
45
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<DollarSign className="w-6 h-6
|
||||
text-green-600"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
+8%
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
Total Spending
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
{formatCurrency(12450)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Hotel className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
Currently Staying
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
2
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<div className="flex items-center
|
||||
justify-between mb-4"
|
||||
>
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<TrendingUp className="w-6 h-6
|
||||
text-orange-600"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-green-600
|
||||
font-medium"
|
||||
>
|
||||
+15%
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-gray-500 text-sm
|
||||
font-medium mb-1"
|
||||
>
|
||||
Reward Points
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-800">
|
||||
1,250
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2
|
||||
gap-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<h2 className="text-xl font-semibold
|
||||
text-gray-800 mb-4"
|
||||
>
|
||||
Recent Activity
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
action: 'Booking',
|
||||
room: 'Room 201',
|
||||
time: '2 hours ago'
|
||||
},
|
||||
{
|
||||
action: 'Check-in',
|
||||
room: 'Room 105',
|
||||
time: '1 day ago'
|
||||
},
|
||||
{
|
||||
action: 'Check-out',
|
||||
room: 'Room 302',
|
||||
time: '3 days ago'
|
||||
},
|
||||
].map((activity, index) => (
|
||||
<div key={index}
|
||||
className="flex items-center space-x-4
|
||||
pb-4 border-b border-gray-200
|
||||
last:border-0"
|
||||
>
|
||||
<div className="p-2 bg-blue-100
|
||||
rounded-lg"
|
||||
>
|
||||
<Activity className="w-5 h-5
|
||||
text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-800">
|
||||
{activity.action}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{activity.room}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{activity.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
>
|
||||
<h2 className="text-xl font-semibold
|
||||
text-gray-800 mb-4"
|
||||
>
|
||||
Upcoming Bookings
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
room: 'Room 401',
|
||||
date: '20/11/2025',
|
||||
status: 'Confirmed'
|
||||
},
|
||||
{
|
||||
room: 'Room 203',
|
||||
date: '25/11/2025',
|
||||
status: 'Pending confirmation'
|
||||
},
|
||||
].map((booking, index) => (
|
||||
<div key={index}
|
||||
className="flex items-center
|
||||
justify-between pb-4 border-b
|
||||
border-gray-200 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
{booking.room}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{booking.date}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full
|
||||
text-xs font-medium
|
||||
${booking.status === 'Confirmed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{booking.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
546
Frontend/src/pages/customer/DepositPaymentPage.tsx
Normal file
546
Frontend/src/pages/customer/DepositPaymentPage.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
CreditCard,
|
||||
Building2,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getBookingById, type Booking } from
|
||||
'../../services/api/bookingService';
|
||||
import {
|
||||
getPaymentsByBookingId,
|
||||
getBankTransferInfo,
|
||||
notifyPaymentCompletion,
|
||||
type Payment,
|
||||
type BankInfo,
|
||||
} from '../../services/api/paymentService';
|
||||
import Loading from '../../components/common/Loading';
|
||||
|
||||
const DepositPaymentPage: React.FC = () => {
|
||||
const { bookingId } = useParams<{ bookingId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
|
||||
const [bankInfo, setBankInfo] = useState<BankInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notifying, setNotifying] = useState(false);
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<
|
||||
'bank_transfer' | null
|
||||
>('bank_transfer');
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId) {
|
||||
fetchData(Number(bookingId));
|
||||
}
|
||||
}, [bookingId]);
|
||||
|
||||
const fetchData = async (id: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch booking details
|
||||
const bookingResponse = await getBookingById(id);
|
||||
if (!bookingResponse.success || !bookingResponse.data?.booking) {
|
||||
throw new Error('Booking not found');
|
||||
}
|
||||
|
||||
const bookingData = bookingResponse.data.booking;
|
||||
setBooking(bookingData);
|
||||
|
||||
// Check if booking requires deposit
|
||||
if (!bookingData.requires_deposit) {
|
||||
toast.info('This booking does not require a deposit');
|
||||
navigate(`/bookings/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch payments
|
||||
const paymentsResponse = await getPaymentsByBookingId(id);
|
||||
if (paymentsResponse.success) {
|
||||
const deposit = paymentsResponse.data.payments.find(
|
||||
(p) => p.payment_type === 'deposit'
|
||||
);
|
||||
if (deposit) {
|
||||
setDepositPayment(deposit);
|
||||
|
||||
// If payment is pending, fetch bank info
|
||||
if (deposit.payment_status === 'pending') {
|
||||
const bankInfoResponse = await getBankTransferInfo(deposit.id);
|
||||
if (bankInfoResponse.success && bankInfoResponse.data.bank_info) {
|
||||
setBankInfo(bankInfoResponse.data.bank_info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching data:', err);
|
||||
const message =
|
||||
err.response?.data?.message || 'Unable to load payment information';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedText(label);
|
||||
toast.success(`Copied ${label}`);
|
||||
setTimeout(() => setCopiedText(null), 2000);
|
||||
} catch (err) {
|
||||
toast.error('Unable to copy');
|
||||
}
|
||||
};
|
||||
|
||||
// No auto-redirect payment methods. Default to bank transfer.
|
||||
|
||||
const handleNotifyPayment = async () => {
|
||||
if (!depositPayment) return;
|
||||
|
||||
try {
|
||||
setNotifying(true);
|
||||
const response = await notifyPaymentCompletion(
|
||||
depositPayment.id,
|
||||
'Customer has transferred deposit'
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
'✅ Payment notification sent! ' +
|
||||
'We will confirm within 24 hours.'
|
||||
);
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
} else {
|
||||
throw new Error(response.message || 'Unable to send notification');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error notifying payment:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to send notification. Please try again.';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setNotifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// VNPay removed: no online redirect handler
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading..." />;
|
||||
}
|
||||
|
||||
if (error || !booking || !depositPayment) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
>
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
{error || 'Payment information not found'}
|
||||
</p>
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="inline-flex items-center gap-2 px-6 py-2
|
||||
bg-red-600 text-white rounded-lg hover:bg-red-700
|
||||
transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to booking list
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const depositAmount = parseFloat(depositPayment.amount.toString());
|
||||
const remainingAmount = booking.total_price - depositAmount;
|
||||
const isDepositPaid = depositPayment.payment_status === 'completed';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to={`/bookings/${bookingId}`}
|
||||
className="inline-flex items-center gap-2 text-gray-600
|
||||
hover:text-gray-900 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to booking details</span>
|
||||
</Link>
|
||||
|
||||
{/* Success Header (if paid) */}
|
||||
{isDepositPaid && (
|
||||
<div
|
||||
className="bg-green-50 border-2 border-green-200
|
||||
rounded-lg p-6 mb-6"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100 rounded-full
|
||||
flex items-center justify-center"
|
||||
>
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-green-900 mb-1">
|
||||
Deposit payment successful!
|
||||
</h1>
|
||||
<p className="text-green-700">
|
||||
Your booking has been confirmed.
|
||||
Remaining amount to be paid at check-in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Header */}
|
||||
{!isDepositPaid && (
|
||||
<div
|
||||
className="bg-orange-50 border-2 border-orange-200
|
||||
rounded-lg p-6 mb-6"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 bg-orange-100 rounded-full
|
||||
flex items-center justify-center"
|
||||
>
|
||||
<CreditCard className="w-10 h-10 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-orange-900 mb-1">
|
||||
Deposit Payment
|
||||
</h1>
|
||||
<p className="text-orange-700">
|
||||
Please pay <strong>20% deposit</strong> to
|
||||
confirm your booking
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Payment Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Payment Summary */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Payment Information
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Total Room Price</span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between border-t pt-3
|
||||
text-orange-600"
|
||||
>
|
||||
<span className="font-medium">
|
||||
Deposit Amount to Pay (20%)
|
||||
</span>
|
||||
<span className="text-xl font-bold">
|
||||
{formatPrice(depositAmount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<span>Remaining amount to be paid at check-in</span>
|
||||
<span>{formatPrice(remainingAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDepositPaid && (
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded p-3">
|
||||
<p className="text-sm text-green-800">
|
||||
✓ Deposit paid on:{' '}
|
||||
{depositPayment.payment_date
|
||||
? new Date(depositPayment.payment_date).toLocaleString('en-US')
|
||||
: 'N/A'}
|
||||
</p>
|
||||
{depositPayment.transaction_id && (
|
||||
<p className="text-xs text-green-700 mt-1">
|
||||
Transaction ID: {depositPayment.transaction_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method Selection */}
|
||||
{!isDepositPaid && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">
|
||||
Select Payment Method
|
||||
</h2>
|
||||
|
||||
{/* Payment Method Buttons */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
{/* Bank Transfer Button */}
|
||||
<button
|
||||
onClick={() => setSelectedPaymentMethod('bank_transfer')}
|
||||
className={`p-4 border-2 rounded-lg transition-all
|
||||
${
|
||||
selectedPaymentMethod === 'bank_transfer'
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-300 bg-white hover:border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<Building2
|
||||
className={`w-8 h-8 mx-auto mb-2 ${
|
||||
selectedPaymentMethod === 'bank_transfer'
|
||||
? 'text-indigo-600'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`font-bold text-sm ${
|
||||
selectedPaymentMethod === 'bank_transfer'
|
||||
? 'text-indigo-900'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Bank Transfer
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Bank transfer
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* VNPay removed */}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bank Transfer Instructions or VNPay panel */}
|
||||
{!isDepositPaid && selectedPaymentMethod === 'bank_transfer' &&
|
||||
bankInfo && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
<Building2 className="w-5 h-5 inline mr-2" />
|
||||
Bank Transfer Information
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Bank Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Bank</div>
|
||||
<div className="font-medium">{bankInfo.bank_name}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(bankInfo.bank_name, 'bank name')
|
||||
}
|
||||
className="p-2 hover:bg-gray-200 rounded transition-colors"
|
||||
>
|
||||
{copiedText === 'bank name' ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Account Number</div>
|
||||
<div className="font-medium font-mono">
|
||||
{bankInfo.account_number}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(bankInfo.account_number, 'account number')
|
||||
}
|
||||
className="p-2 hover:bg-gray-200 rounded transition-colors"
|
||||
>
|
||||
{copiedText === 'account number' ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Account Holder</div>
|
||||
<div className="font-medium">{bankInfo.account_name}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(bankInfo.account_name, 'account holder')
|
||||
}
|
||||
className="p-2 hover:bg-gray-200 rounded transition-colors"
|
||||
>
|
||||
{copiedText === 'account holder' ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-3 bg-orange-50 border border-orange-200 rounded">
|
||||
<div>
|
||||
<div className="text-xs text-orange-700">Amount</div>
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{formatPrice(bankInfo.amount)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(bankInfo.amount.toString(), 'amount')
|
||||
}
|
||||
className="p-2 hover:bg-orange-100 rounded transition-colors"
|
||||
>
|
||||
{copiedText === 'amount' ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-orange-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Transfer Content</div>
|
||||
<div className="font-medium font-mono text-red-600">
|
||||
{bankInfo.content}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(bankInfo.content, 'content')
|
||||
}
|
||||
className="p-2 hover:bg-gray-200 rounded transition-colors"
|
||||
>
|
||||
{copiedText === 'content' ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>⚠️ Note:</strong> Please enter the correct transfer content so
|
||||
the system can automatically confirm the payment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notify Button */}
|
||||
<button
|
||||
onClick={handleNotifyPayment}
|
||||
disabled={notifying}
|
||||
className="w-full bg-indigo-600 text-white py-3 rounded-lg
|
||||
hover:bg-indigo-700 transition-colors font-semibold
|
||||
disabled:bg-gray-400 disabled:cursor-not-allowed
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{notifying ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
I have transferred
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-xs text-center text-gray-500 mt-2">
|
||||
After transferring, click the button above to notify us
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNPay removed */}
|
||||
</div>
|
||||
|
||||
{/* QR Code Sidebar */}
|
||||
{!isDepositPaid &&
|
||||
bankInfo &&
|
||||
selectedPaymentMethod === 'bank_transfer' && (
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 sticky top-8">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4 text-center">
|
||||
Scan QR Code to Pay
|
||||
</h3>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg mb-4">
|
||||
<img
|
||||
src={bankInfo.qr_url}
|
||||
alt="QR Code"
|
||||
className="w-full h-auto rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
Scan QR code with your bank app
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Transfer information has been automatically filled
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={bankInfo.qr_url}
|
||||
download={`deposit-qr-${booking.booking_number}.jpg`}
|
||||
className="mt-4 w-full inline-flex items-center justify-center
|
||||
gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200
|
||||
text-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download QR Code
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DepositPaymentPage;
|
||||
201
Frontend/src/pages/customer/FavoritesPage.tsx
Normal file
201
Frontend/src/pages/customer/FavoritesPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Heart, AlertCircle, ArrowLeft } from 'lucide-react';
|
||||
import { RoomCard, RoomCardSkeleton } from
|
||||
'../../components/rooms';
|
||||
import useFavoritesStore from
|
||||
'../../store/useFavoritesStore';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
const FavoritesPage: React.FC = () => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const {
|
||||
favorites,
|
||||
isLoading,
|
||||
error,
|
||||
fetchFavorites
|
||||
} = useFavoritesStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchFavorites();
|
||||
}
|
||||
}, [isAuthenticated, fetchFavorites]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div
|
||||
className="bg-yellow-50 border
|
||||
border-yellow-200 rounded-lg
|
||||
p-8 text-center"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-yellow-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<h3
|
||||
className="text-xl font-bold
|
||||
text-gray-900 mb-2"
|
||||
>
|
||||
Please Login
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
You need to login to view your favorites list
|
||||
</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-block px-6 py-3
|
||||
bg-indigo-600 text-white rounded-lg
|
||||
hover:bg-indigo-700 transition-colors
|
||||
font-semibold"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2
|
||||
text-gray-600 hover:text-gray-900
|
||||
mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to home</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Heart
|
||||
className="w-8 h-8 text-red-500"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<div>
|
||||
<h1
|
||||
className="text-3xl font-bold
|
||||
text-gray-900"
|
||||
>
|
||||
Favorites List
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{favorites.length > 0
|
||||
? `${favorites.length} room${favorites.length !== 1 ? 's' : ''}`
|
||||
: 'No favorite rooms yet'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={fetchFavorites}
|
||||
className="px-6 py-2 bg-red-600
|
||||
text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
favorites.length === 0 && (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
p-12 text-center"
|
||||
>
|
||||
<div
|
||||
className="w-24 h-24 bg-gray-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-6"
|
||||
>
|
||||
<Heart
|
||||
className="w-12 h-12 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-2xl font-bold
|
||||
text-gray-900 mb-3"
|
||||
>
|
||||
No favorite rooms yet
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className="text-gray-600 mb-6
|
||||
max-w-md mx-auto"
|
||||
>
|
||||
You haven't added any rooms to your favorites list yet. Explore and save the rooms you like!
|
||||
</p>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-block px-6 py-3
|
||||
bg-indigo-600 text-white rounded-lg
|
||||
hover:bg-indigo-700 transition-colors
|
||||
font-semibold"
|
||||
>
|
||||
Explore rooms
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorites Grid */}
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
favorites.length > 0 && (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{favorites.map((favorite) =>
|
||||
favorite.room ? (
|
||||
<RoomCard
|
||||
key={favorite.id}
|
||||
room={favorite.room}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoritesPage;
|
||||
639
Frontend/src/pages/customer/MyBookingsPage.tsx
Normal file
639
Frontend/src/pages/customer/MyBookingsPage.tsx
Normal file
@@ -0,0 +1,639 @@
|
||||
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';
|
||||
|
||||
const MyBookingsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
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) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(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) => {
|
||||
// Can only cancel pending or confirmed bookings
|
||||
return (
|
||||
booking.status === 'pending' ||
|
||||
booking.status === 'confirmed'
|
||||
);
|
||||
};
|
||||
|
||||
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 */}
|
||||
{roomType?.images?.[0] && (
|
||||
<div className="lg:w-48 flex-shrink-0">
|
||||
<img
|
||||
src={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
|
||||
onClick={() =>
|
||||
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"
|
||||
>
|
||||
{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;
|
||||
536
Frontend/src/pages/customer/PaymentConfirmationPage.tsx
Normal file
536
Frontend/src/pages/customer/PaymentConfirmationPage.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
useNavigate,
|
||||
Link
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Copy,
|
||||
Check,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
getBookingById,
|
||||
generateQRCode,
|
||||
type Booking,
|
||||
} from '../../services/api/bookingService';
|
||||
import { confirmBankTransfer } from
|
||||
'../../services/api/paymentService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import PaymentStatusBadge from
|
||||
'../../components/common/PaymentStatusBadge';
|
||||
|
||||
const PaymentConfirmationPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(
|
||||
null
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
const [selectedFile, setSelectedFile] =
|
||||
useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] =
|
||||
useState<string | null>(null);
|
||||
const [copiedBookingNumber, setCopiedBookingNumber] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error(
|
||||
'Please login to confirm payment'
|
||||
);
|
||||
navigate('/login');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && isAuthenticated) {
|
||||
fetchBookingDetails(Number(id));
|
||||
}
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
const fetchBookingDetails = async (bookingId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await getBookingById(bookingId);
|
||||
|
||||
if (
|
||||
response.success &&
|
||||
response.data?.booking
|
||||
) {
|
||||
const bookingData = response.data.booking;
|
||||
|
||||
// Check if already paid
|
||||
if (bookingData.payment_status === 'paid') {
|
||||
toast.info('This booking has already been paid');
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if payment method is cash
|
||||
if (bookingData.payment_method === 'cash') {
|
||||
toast.info(
|
||||
'This booking uses on-site payment method'
|
||||
);
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setBooking(bookingData);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unable to load booking information'
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching booking:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load booking information';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const copyBookingNumber = async () => {
|
||||
if (!booking?.booking_number) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
booking.booking_number
|
||||
);
|
||||
setCopiedBookingNumber(true);
|
||||
toast.success('Booking number copied');
|
||||
setTimeout(() => setCopiedBookingNumber(false), 2000);
|
||||
} catch (err) {
|
||||
toast.error('Unable to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error(
|
||||
'Image size must not exceed 5MB'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewUrl(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleConfirmPayment = async () => {
|
||||
if (!selectedFile || !booking) return;
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
const transactionId =
|
||||
`TXN-${booking.booking_number}-${Date.now()}`;
|
||||
|
||||
const response = await confirmBankTransfer(
|
||||
booking.id,
|
||||
transactionId,
|
||||
selectedFile
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
'✅ Payment confirmation sent successfully!'
|
||||
);
|
||||
setUploadSuccess(true);
|
||||
|
||||
// Redirect after 2 seconds
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message ||
|
||||
'Unable to confirm payment'
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error confirming payment:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to send payment confirmation';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading..." />;
|
||||
}
|
||||
|
||||
if (error || !booking) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
{error || 'Booking not found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/bookings')}
|
||||
className="px-6 py-2 bg-red-600
|
||||
text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Back to list
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const qrCodeUrl = generateQRCode(
|
||||
booking.booking_number,
|
||||
booking.total_price
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to={`/bookings/${booking.id}`}
|
||||
className="inline-flex items-center gap-2
|
||||
text-gray-600 hover:text-gray-900
|
||||
mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to booking details</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900
|
||||
mb-2"
|
||||
>
|
||||
Payment Confirmation
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Complete payment for your booking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Booking Info Card */}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
<div className="flex items-start justify-between
|
||||
gap-4 mb-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
Booking Number
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold
|
||||
text-indigo-900 font-mono"
|
||||
>
|
||||
{booking.booking_number}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyBookingNumber}
|
||||
className="p-1 hover:bg-gray-100
|
||||
rounded transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
{copiedBookingNumber ? (
|
||||
<Check className="w-4 h-4
|
||||
text-green-600"
|
||||
/>
|
||||
) : (
|
||||
<Copy className="w-4 h-4
|
||||
text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaymentStatusBadge
|
||||
status={booking.payment_status}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between
|
||||
items-center"
|
||||
>
|
||||
<span className="text-gray-600">
|
||||
Total Payment
|
||||
</span>
|
||||
<span className="text-2xl font-bold
|
||||
text-indigo-600"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!uploadSuccess ? (
|
||||
<>
|
||||
{/* Bank Transfer Instructions */}
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-6 mb-6"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Building2
|
||||
className="w-6 h-6 text-blue-600
|
||||
mt-1 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900
|
||||
mb-3"
|
||||
>
|
||||
Bank Transfer Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-2 gap-4"
|
||||
>
|
||||
{/* Bank Info */}
|
||||
<div className="bg-white rounded-lg
|
||||
p-4 space-y-2 text-sm"
|
||||
>
|
||||
<p>
|
||||
<strong>Bank:</strong>
|
||||
Vietcombank (VCB)
|
||||
</p>
|
||||
<p>
|
||||
<strong>Account Number:</strong>
|
||||
0123456789
|
||||
</p>
|
||||
<p>
|
||||
<strong>Account Holder:</strong>
|
||||
KHACH SAN ABC
|
||||
</p>
|
||||
<p>
|
||||
<strong>Amount:</strong>{' '}
|
||||
<span className="text-indigo-600
|
||||
font-bold"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Content:</strong>{' '}
|
||||
<span className="font-mono
|
||||
text-indigo-600"
|
||||
>
|
||||
{booking.booking_number}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="bg-white rounded-lg
|
||||
p-4 flex flex-col items-center"
|
||||
>
|
||||
<p className="text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
>
|
||||
Scan QR code to transfer
|
||||
</p>
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="QR Code"
|
||||
className="w-48 h-48 border-2
|
||||
border-gray-200 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Receipt Section */}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
<h3 className="text-xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
<Upload className="w-5 h-5 inline mr-2" />
|
||||
Upload Payment Receipt
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
After transferring, please upload a receipt image
|
||||
so we can confirm faster.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Input */}
|
||||
<label
|
||||
htmlFor="receipt-upload"
|
||||
className="block w-full px-4 py-6
|
||||
border-2 border-dashed
|
||||
border-gray-300 rounded-lg
|
||||
text-center cursor-pointer
|
||||
hover:border-indigo-400
|
||||
hover:bg-indigo-50
|
||||
transition-all"
|
||||
>
|
||||
<input
|
||||
id="receipt-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{previewUrl ? (
|
||||
<div className="flex flex-col
|
||||
items-center gap-3"
|
||||
>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="w-48 h-48 object-cover
|
||||
rounded-lg border-2
|
||||
border-indigo-200"
|
||||
/>
|
||||
<p className="text-sm text-indigo-600
|
||||
font-medium"
|
||||
>
|
||||
{selectedFile?.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Click to select another image
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col
|
||||
items-center gap-2"
|
||||
>
|
||||
<FileText
|
||||
className="w-12 h-12 text-gray-400"
|
||||
/>
|
||||
<p className="text-sm text-gray-600
|
||||
font-medium"
|
||||
>
|
||||
Click to select receipt image
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
PNG, JPG, JPEG (Max 5MB)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Upload Button */}
|
||||
{selectedFile && (
|
||||
<button
|
||||
onClick={handleConfirmPayment}
|
||||
disabled={uploading}
|
||||
className="w-full px-6 py-4
|
||||
bg-indigo-600 text-white
|
||||
rounded-lg hover:bg-indigo-700
|
||||
transition-colors font-semibold
|
||||
text-lg disabled:bg-gray-400
|
||||
disabled:cursor-not-allowed
|
||||
flex items-center justify-center
|
||||
gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="w-5 h-5 animate-spin"
|
||||
/>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Confirm Payment
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-green-50 border
|
||||
border-green-200 rounded-lg p-8
|
||||
text-center"
|
||||
>
|
||||
<CheckCircle
|
||||
className="w-16 h-16 text-green-600
|
||||
mx-auto mb-4"
|
||||
/>
|
||||
<h3 className="text-2xl font-bold
|
||||
text-green-900 mb-2"
|
||||
>
|
||||
Confirmation sent successfully!
|
||||
</h3>
|
||||
<p className="text-green-700 mb-4">
|
||||
We will confirm your payment as soon as possible.
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Redirecting...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentConfirmationPage;
|
||||
251
Frontend/src/pages/customer/PaymentResultPage.tsx
Normal file
251
Frontend/src/pages/customer/PaymentResultPage.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Home,
|
||||
Receipt,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
const PaymentResultPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [countdown, setCountdown] = useState(10);
|
||||
|
||||
const status = searchParams.get('status');
|
||||
const bookingId = searchParams.get('bookingId');
|
||||
const transactionId = searchParams.get('transactionId');
|
||||
const message = searchParams.get('message');
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'success' && bookingId) {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [status, bookingId, navigate]);
|
||||
|
||||
const getStatusContent = () => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return {
|
||||
icon: <CheckCircle className="w-20 h-20 text-green-500" />,
|
||||
title: 'Payment Successful!',
|
||||
description:
|
||||
'Thank you for your payment. Your booking has been confirmed.',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-800',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
icon: <XCircle className="w-20 h-20 text-red-500" />,
|
||||
title: 'Payment Failed',
|
||||
description: message ||
|
||||
'Transaction was not successful. Please try again.',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
textColor: 'text-red-800',
|
||||
};
|
||||
case 'invalid_signature':
|
||||
return {
|
||||
icon: <AlertCircle className="w-20 h-20 text-orange-500" />,
|
||||
title: 'Authentication Error',
|
||||
description:
|
||||
'Unable to verify transaction. Please contact support.',
|
||||
bgColor: 'bg-orange-50',
|
||||
borderColor: 'border-orange-200',
|
||||
textColor: 'text-orange-800',
|
||||
};
|
||||
case 'payment_not_found':
|
||||
return {
|
||||
icon: <AlertCircle className="w-20 h-20 text-gray-500" />,
|
||||
title: 'Payment Not Found',
|
||||
description:
|
||||
'Payment information not found. ' +
|
||||
'Please check again.',
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
textColor: 'text-gray-800',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <AlertCircle className="w-20 h-20 text-gray-500" />,
|
||||
title: 'Unknown Error',
|
||||
description: message ||
|
||||
'An error occurred. Please try again later.',
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
textColor: 'text-gray-800',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const content = getStatusContent();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<div
|
||||
className={`${content.bgColor} border-2
|
||||
${content.borderColor} rounded-lg p-8`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center mb-6">
|
||||
{content.icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1
|
||||
className={`text-3xl font-bold text-center
|
||||
mb-4 ${content.textColor}`}
|
||||
>
|
||||
{content.title}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-center text-gray-700 mb-6">
|
||||
{content.description}
|
||||
</p>
|
||||
|
||||
{/* Transaction Details */}
|
||||
{status === 'success' && transactionId && (
|
||||
<div
|
||||
className="bg-white border border-gray-200
|
||||
rounded-lg p-4 mb-6"
|
||||
>
|
||||
<div className="text-sm space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">
|
||||
Transaction ID
|
||||
</span>
|
||||
<span className="font-medium font-mono">
|
||||
{transactionId}
|
||||
</span>
|
||||
</div>
|
||||
{bookingId && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">
|
||||
Booking ID
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
#{bookingId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto redirect notice for success */}
|
||||
{status === 'success' && bookingId && countdown > 0 && (
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex items-center justify-center gap-2
|
||||
text-gray-600"
|
||||
>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>
|
||||
Auto redirecting to booking details in {countdown}s...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{status === 'success' && bookingId ? (
|
||||
<>
|
||||
<Link
|
||||
to={`/bookings/${bookingId}`}
|
||||
className="flex-1 flex items-center justify-center
|
||||
gap-2 px-6 py-3 bg-green-600 text-white
|
||||
rounded-lg hover:bg-green-700
|
||||
transition-colors font-medium"
|
||||
>
|
||||
<Receipt className="w-5 h-5" />
|
||||
View booking details
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex-1 flex items-center justify-center
|
||||
gap-2 px-6 py-3 bg-white text-gray-700
|
||||
border-2 border-gray-300 rounded-lg
|
||||
hover:bg-gray-50 transition-colors
|
||||
font-medium"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
Go to home
|
||||
</Link>
|
||||
</>
|
||||
) : status === 'failed' && bookingId ? (
|
||||
<>
|
||||
<Link
|
||||
to={`/deposit-payment/${bookingId}`}
|
||||
className="flex-1 px-6 py-3 bg-indigo-600
|
||||
text-white rounded-lg hover:bg-indigo-700
|
||||
transition-colors font-medium text-center"
|
||||
>
|
||||
Retry payment
|
||||
</Link>
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="flex-1 px-6 py-3 bg-white
|
||||
text-gray-700 border-2 border-gray-300
|
||||
rounded-lg hover:bg-gray-50
|
||||
transition-colors font-medium text-center"
|
||||
>
|
||||
Booking list
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full flex items-center
|
||||
justify-center gap-2 px-6 py-3
|
||||
bg-indigo-600 text-white rounded-lg
|
||||
hover:bg-indigo-700 transition-colors
|
||||
font-medium"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
Go to home
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Support Notice */}
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
<p>
|
||||
If you have any issues, please contact{' '}
|
||||
<a
|
||||
href="mailto:support@hotel.com"
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
support@hotel.com
|
||||
</a>{' '}
|
||||
or call{' '}
|
||||
<a
|
||||
href="tel:1900xxxx"
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
1900 xxxx
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentResultPage;
|
||||
281
Frontend/src/pages/customer/RoomDetailPage.tsx
Normal file
281
Frontend/src/pages/customer/RoomDetailPage.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Users,
|
||||
MapPin,
|
||||
DollarSign,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { getRoomById, type Room } from
|
||||
'../../services/api/roomService';
|
||||
import RoomGallery from '../../components/rooms/RoomGallery';
|
||||
import RoomAmenities from '../../components/rooms/RoomAmenities';
|
||||
import ReviewSection from '../../components/rooms/ReviewSection';
|
||||
import RatingStars from '../../components/rooms/RatingStars';
|
||||
|
||||
const RoomDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchRoomDetail(Number(id));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchRoomDetail = async (roomId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getRoomById(roomId);
|
||||
|
||||
// backend uses `status: 'success'` (not `success`), accept both
|
||||
if ((response as any).success || (response as any).status === 'success') {
|
||||
if (response.data && response.data.room) {
|
||||
setRoom(response.data.room);
|
||||
} else {
|
||||
throw new Error('Failed to fetch room details');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to fetch room details');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching room:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load room information';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-96 bg-gray-300 rounded-lg" />
|
||||
<div className="h-8 bg-gray-300 rounded w-1/3" />
|
||||
<div className="h-4 bg-gray-300 rounded w-2/3" />
|
||||
<div className="h-32 bg-gray-300 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !room) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
>
|
||||
<p className="text-red-800 font-medium mb-4">
|
||||
{error || 'Room not found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/rooms')}
|
||||
className="inline-flex items-center gap-2 bg-indigo-600
|
||||
text-white px-3 py-2 rounded-md hover:bg-indigo-700
|
||||
disabled:bg-gray-400 mb-6 transition-colors"
|
||||
>
|
||||
Back to Room List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roomType = room.room_type;
|
||||
const formattedPrice = new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(roomType?.base_price || 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center gap-2 bg-indigo-600
|
||||
text-white px-3 py-2 rounded-md hover:bg-indigo-700
|
||||
disabled:bg-gray-400 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to room list</span>
|
||||
</Link>
|
||||
|
||||
{/* Image Gallery */}
|
||||
<div className="mb-8">
|
||||
<RoomGallery
|
||||
images={roomType?.images || []}
|
||||
roomName={roomType?.name || 'Room'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Room Information */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-12">
|
||||
{/* Main Info */}
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
{/* Title & Basic Info */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
{roomType?.name}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center
|
||||
gap-6 text-gray-600 mb-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<span>
|
||||
Room {room.room_number} - Floor {room.floor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
<span>
|
||||
{roomType?.capacity || 0} guests
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{room.average_rating != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<RatingStars
|
||||
rating={Number(room.average_rating)}
|
||||
size="sm"
|
||||
showNumber
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
({room.total_reviews || 0} đánh giá)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div
|
||||
className={`inline-block px-4 py-2
|
||||
rounded-full text-sm font-semibold
|
||||
${
|
||||
room.status === 'available'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: room.status === 'occupied'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{room.status === 'available'
|
||||
? 'Available'
|
||||
: room.status === 'occupied'
|
||||
? 'Booked'
|
||||
: 'Maintenance'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{roomType?.description && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
Room Description
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
{roomType.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Amenities */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
Amenities
|
||||
</h2>
|
||||
<RoomAmenities
|
||||
amenities={
|
||||
(room.amenities && room.amenities.length > 0)
|
||||
? room.amenities
|
||||
: (roomType?.amenities || [])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Card */}
|
||||
<aside className="lg:col-span-4">
|
||||
<div className="bg-white rounded-xl shadow-md p-6 sticky top-6">
|
||||
<div className="flex items-baseline gap-3 mb-4">
|
||||
<DollarSign className="w-5 h-5 text-gray-600" />
|
||||
<div>
|
||||
<div className="text-3xl font-extrabold text-indigo-600">
|
||||
{formattedPrice}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">/ night</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
to={`/booking/${room.id}`}
|
||||
className={`block w-full py-3 text-center font-semibold rounded-md transition-colors ${
|
||||
room.status === 'available'
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (room.status !== 'available') e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{room.status === 'available' ? 'Book Now' : 'Not Available'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{room.status === 'available' && (
|
||||
<p className="text-sm text-gray-500 text-center mt-3">
|
||||
No immediate charge — pay at the hotel
|
||||
</p>
|
||||
)}
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<div className="text-sm text-gray-700 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Room Type</span>
|
||||
<strong>{roomType?.name}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Guests</span>
|
||||
<span>{roomType?.capacity} guests</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Rooms</span>
|
||||
<span>1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Reviews Section */}
|
||||
<div className="mb-12">
|
||||
<ReviewSection roomId={room.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomDetailPage;
|
||||
194
Frontend/src/pages/customer/RoomListPage.tsx
Normal file
194
Frontend/src/pages/customer/RoomListPage.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { getRooms } from '../../services/api/roomService';
|
||||
import type { Room } from '../../services/api/roomService';
|
||||
import RoomFilter from '../../components/rooms/RoomFilter';
|
||||
import RoomCard from '../../components/rooms/RoomCard';
|
||||
import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton';
|
||||
import Pagination from '../../components/rooms/Pagination';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
const RoomListPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
// Fetch rooms based on URL params
|
||||
useEffect(() => {
|
||||
const fetchRooms = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = {
|
||||
type: searchParams.get('type') || undefined,
|
||||
minPrice: searchParams.get('minPrice')
|
||||
? Number(searchParams.get('minPrice'))
|
||||
: undefined,
|
||||
maxPrice: searchParams.get('maxPrice')
|
||||
? Number(searchParams.get('maxPrice'))
|
||||
: undefined,
|
||||
capacity: searchParams.get('capacity')
|
||||
? Number(searchParams.get('capacity'))
|
||||
: undefined,
|
||||
page: searchParams.get('page')
|
||||
? Number(searchParams.get('page'))
|
||||
: 1,
|
||||
limit: 12,
|
||||
};
|
||||
|
||||
const response = await getRooms(params);
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
setRooms(response.data.rooms || []);
|
||||
if (response.data.pagination) {
|
||||
setPagination(response.data.pagination);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to fetch rooms');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching rooms:', err);
|
||||
setError('Unable to load room list. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRooms();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 bg-indigo-600
|
||||
text-white px-3 py-2 rounded-md hover:bg-indigo-700
|
||||
disabled:bg-gray-400 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to home</span>
|
||||
</Link>
|
||||
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl text-center font-bold text-gray-900">
|
||||
Room List
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
<aside className="lg:col-span-1">
|
||||
<RoomFilter />
|
||||
</aside>
|
||||
|
||||
<main className="lg:col-span-3">
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
xl:grid-cols-3 gap-6"
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="bg-red-50 border border-red-200
|
||||
rounded-lg p-6 text-center"
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 text-red-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0
|
||||
9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-red-800 font-medium">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-red-600
|
||||
text-white rounded-lg hover:bg-red-700
|
||||
transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-12 text-center"
|
||||
>
|
||||
<svg
|
||||
className="w-24 h-24 text-gray-300 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14
|
||||
0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1
|
||||
4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold
|
||||
text-gray-800 mb-2"
|
||||
>
|
||||
No matching rooms found
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Please try adjusting the filters or search differently
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/rooms'}
|
||||
className="px-6 py-2 bg-blue-600 text-white
|
||||
rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
xl:grid-cols-2 gap-6"
|
||||
>
|
||||
{rooms.map((room) => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomListPage;
|
||||
357
Frontend/src/pages/customer/SearchResultsPage.tsx
Normal file
357
Frontend/src/pages/customer/SearchResultsPage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
useSearchParams,
|
||||
useNavigate,
|
||||
Link
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
Search,
|
||||
Calendar,
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Home,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
RoomCard,
|
||||
RoomCardSkeleton,
|
||||
Pagination,
|
||||
} from '../../components/rooms';
|
||||
import { searchAvailableRooms } from
|
||||
'../../services/api/roomService';
|
||||
import type { Room } from '../../services/api/roomService';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const SearchResultsPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 12,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
// Get search params
|
||||
const from = searchParams.get('from') || '';
|
||||
const to = searchParams.get('to') || '';
|
||||
const type = searchParams.get('type') || '';
|
||||
const capacityParam = searchParams.get('capacity') || '';
|
||||
const capacity = capacityParam ? Number(capacityParam) : undefined;
|
||||
const pageParam = searchParams.get('page') || '';
|
||||
const page = pageParam ? Number(pageParam) : 1;
|
||||
|
||||
useEffect(() => {
|
||||
// Validate required params
|
||||
if (!from || !to) {
|
||||
toast.error(
|
||||
'Missing search information. ' +
|
||||
'Please select check-in and check-out dates.'
|
||||
);
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchAvailableRooms();
|
||||
}, [from, to, type, capacity, page]);
|
||||
|
||||
const fetchAvailableRooms = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await searchAvailableRooms({
|
||||
from,
|
||||
to,
|
||||
type: type || undefined,
|
||||
capacity: capacity || undefined,
|
||||
page,
|
||||
limit: 12,
|
||||
});
|
||||
|
||||
if (
|
||||
response.success ||
|
||||
response.status === 'success'
|
||||
) {
|
||||
setRooms(response.data.rooms || []);
|
||||
if (response.data.pagination) {
|
||||
setPagination(response.data.pagination);
|
||||
} else {
|
||||
// Fallback compute
|
||||
const total = response.data.rooms
|
||||
? response.data.rooms.length
|
||||
: 0;
|
||||
const limit = 12;
|
||||
setPagination({
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unable to search rooms');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error searching rooms:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to search available rooms';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 bg-indigo-600
|
||||
text-white px-3 py-2 rounded-md hover:bg-indigo-700
|
||||
disabled:bg-gray-400 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to home</span>
|
||||
</Link>
|
||||
|
||||
{/* Search Info Header */}
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
p-6 mb-8"
|
||||
>
|
||||
<div className="flex items-start justify-between
|
||||
flex-wrap gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
className="text-3xl font-bold
|
||||
text-gray-900 mb-4"
|
||||
>
|
||||
Search Results
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap items-center
|
||||
gap-4 text-gray-700"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Calendar className="w-5 h-5
|
||||
text-indigo-600"
|
||||
/>
|
||||
<span>
|
||||
<strong>Check-in:</strong>{' '}
|
||||
{formatDate(from)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Calendar className="w-5 h-5
|
||||
text-indigo-600"
|
||||
/>
|
||||
<span>
|
||||
<strong>Check-out:</strong>{' '}
|
||||
{formatDate(to)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{type && (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Home className="w-5 h-5
|
||||
text-indigo-600"
|
||||
/>
|
||||
<span>
|
||||
<strong>Room Type:</strong>{' '}
|
||||
{type}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{capacity && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-indigo-600" />
|
||||
<span>
|
||||
<strong>Guests:</strong>{' '}
|
||||
{capacity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-4 py-2 border border-gray-300
|
||||
bg-indigo-600 text-white rounded-lg
|
||||
hover:bg-indigo-700 disabled:bg-gray-400
|
||||
transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
New Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div>
|
||||
<p
|
||||
className="text-gray-600 mb-6
|
||||
text-center animate-pulse"
|
||||
>
|
||||
Searching for available rooms...
|
||||
</p>
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
rounded-lg p-8 text-center"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={fetchAvailableRooms}
|
||||
className="px-6 py-2 bg-red-600
|
||||
text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{rooms.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center
|
||||
justify-between mb-6"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-1
|
||||
md:grid-cols-2 lg:grid-cols-3
|
||||
gap-6"
|
||||
>
|
||||
{rooms.map((room) => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Empty State
|
||||
<div
|
||||
className="bg-white rounded-lg
|
||||
shadow-sm p-12 text-center"
|
||||
>
|
||||
<div
|
||||
className="w-24 h-24 bg-gray-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-6"
|
||||
>
|
||||
<Search
|
||||
className="w-12 h-12 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-2xl font-bold
|
||||
text-gray-900 mb-3"
|
||||
>
|
||||
No matching rooms found
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className="text-gray-600 mb-6
|
||||
max-w-md mx-auto"
|
||||
>
|
||||
Sorry, there are no available rooms
|
||||
for the selected dates.
|
||||
Please try searching with different dates
|
||||
or room types.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex flex-col sm:flex-row
|
||||
gap-3 justify-center"
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-6 py-3 bg-indigo-600
|
||||
text-white rounded-lg
|
||||
hover:bg-indigo-700
|
||||
transition-colors font-semibold
|
||||
inline-flex items-center
|
||||
justify-center gap-2"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search Again
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="px-6 py-3 border
|
||||
border-gray-300 text-gray-700
|
||||
rounded-lg hover:bg-gray-50
|
||||
transition-colors font-semibold
|
||||
inline-flex items-center
|
||||
justify-center gap-2"
|
||||
>
|
||||
View All Rooms
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResultsPage;
|
||||
Reference in New Issue
Block a user