This commit is contained in:
Iliyan Angelov
2025-12-04 01:07:34 +02:00
parent 5fb50983a9
commit 3d634b4fce
92 changed files with 9678 additions and 221 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
LogIn,
LogOut,
@@ -27,6 +27,7 @@ import {
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
import roomService, { Room } from '../../features/rooms/services/roomService';
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
import userService from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import CurrencyIcon from '../../shared/components/CurrencyIcon';
@@ -37,7 +38,7 @@ import { parseDateLocal } from '../../shared/utils/format';
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
import { logger } from '../../shared/utils/logger';
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings' | 'rooms' | 'services';
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'walk-in' | 'bookings' | 'rooms' | 'services';
interface GuestInfo {
name: string;
@@ -77,6 +78,27 @@ const ReceptionDashboardPage: React.FC = () => {
const [discount, setDiscount] = useState(0);
const [showInvoice, setShowInvoice] = useState(false);
// Walk-in booking state
const [walkInLoading, setWalkInLoading] = useState(false);
const [walkInSearchingRooms, setWalkInSearchingRooms] = useState(false);
const [walkInAvailableRooms, setWalkInAvailableRooms] = useState<Room[]>([]);
const [walkInForm, setWalkInForm] = useState({
guestName: '',
guestEmail: '',
guestPhone: '',
guestIdNumber: '',
selectedRoomId: '',
checkInDate: new Date().toISOString().split('T')[0],
checkOutDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0],
numGuests: 1,
numChildren: 0,
specialRequests: '',
paymentMethod: 'cash' as 'cash' | 'stripe',
paymentStatus: 'unpaid' as 'unpaid' | 'deposit' | 'full',
});
const [walkInTotalPrice, setWalkInTotalPrice] = useState(0);
const [walkInSelectedRoom, setWalkInSelectedRoom] = useState<Room | null>(null);
const [bookings, setBookings] = useState<Booking[]>([]);
const [bookingsLoading, setBookingsLoading] = useState(true);
@@ -342,6 +364,139 @@ const ReceptionDashboardPage: React.FC = () => {
setShowInvoice(false);
};
// Walk-in booking handlers
const handleWalkInSearchRooms = async () => {
if (!walkInForm.checkInDate || !walkInForm.checkOutDate) {
toast.error('Please select check-in and check-out dates');
return;
}
try {
setWalkInSearchingRooms(true);
const response = await roomService.searchAvailableRooms({
from: walkInForm.checkInDate,
to: walkInForm.checkOutDate,
limit: 50,
});
setWalkInAvailableRooms(response.data.rooms || []);
if (response.data.rooms && response.data.rooms.length === 0) {
toast.warning('No available rooms for selected dates');
}
} catch (error: any) {
toast.error('Failed to search available rooms');
logger.error('Error searching rooms', error);
} finally {
setWalkInSearchingRooms(false);
}
};
useEffect(() => {
if (walkInForm.checkInDate && walkInForm.checkOutDate && walkInForm.selectedRoomId) {
const selectedRoom = walkInAvailableRooms.find(r => r.id.toString() === walkInForm.selectedRoomId);
if (selectedRoom) {
setWalkInSelectedRoom(selectedRoom);
const checkIn = new Date(walkInForm.checkInDate);
const checkOut = new Date(walkInForm.checkOutDate);
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
const roomPrice = selectedRoom.room_type?.base_price || selectedRoom.price || 0;
setWalkInTotalPrice(roomPrice * nights);
}
}
}, [walkInForm.checkInDate, walkInForm.checkOutDate, walkInForm.selectedRoomId, walkInAvailableRooms]);
const handleWalkInBooking = async () => {
if (!walkInForm.guestName || !walkInForm.guestPhone || !walkInForm.selectedRoomId) {
toast.error('Please fill in all required fields');
return;
}
if (!walkInSelectedRoom) {
toast.error('Please select a room');
return;
}
try {
setWalkInLoading(true);
// First, create or find user
let userId: number;
try {
const userResponse = await userService.getUsers({
search: walkInForm.guestEmail || walkInForm.guestPhone,
role: 'customer',
limit: 1,
});
if (userResponse.data?.users && userResponse.data.users.length > 0) {
userId = userResponse.data.users[0].id;
} else {
// Create new user for walk-in
const createUserResponse = await userService.createUser({
full_name: walkInForm.guestName,
email: walkInForm.guestEmail || `${walkInForm.guestPhone}@walkin.local`,
phone_number: walkInForm.guestPhone,
password: 'temp123', // Temporary password
role: 'customer',
});
userId = createUserResponse.data.user.id;
}
} catch (error: any) {
toast.error('Failed to create/find guest profile');
logger.error('Error creating user', error);
return;
}
// Create booking
const bookingData = {
user_id: userId,
room_id: parseInt(walkInForm.selectedRoomId),
check_in_date: walkInForm.checkInDate,
check_out_date: walkInForm.checkOutDate,
guest_count: walkInForm.numGuests,
total_price: walkInTotalPrice,
status: 'confirmed',
payment_method: walkInForm.paymentMethod,
guest_info: {
full_name: walkInForm.guestName,
email: walkInForm.guestEmail || `${walkInForm.guestPhone}@walkin.local`,
phone: walkInForm.guestPhone,
},
notes: walkInForm.specialRequests || undefined,
};
await bookingService.adminCreateBooking(bookingData);
toast.success('Walk-in booking created successfully!');
// Reset form
setWalkInForm({
guestName: '',
guestEmail: '',
guestPhone: '',
guestIdNumber: '',
selectedRoomId: '',
checkInDate: new Date().toISOString().split('T')[0],
checkOutDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0],
numGuests: 1,
numChildren: 0,
specialRequests: '',
paymentMethod: 'cash',
paymentStatus: 'unpaid',
});
setWalkInAvailableRooms([]);
setWalkInSelectedRoom(null);
setWalkInTotalPrice(0);
// Optionally switch to check-in tab
setActiveTab('check-in');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to create walk-in booking');
logger.error('Error creating walk-in booking', error);
} finally {
setWalkInLoading(false);
}
};
const fetchBookings = useCallback(async () => {
try {
@@ -986,6 +1141,7 @@ const ReceptionDashboardPage: React.FC = () => {
{ id: 'overview' as ReceptionTab, label: 'Overview', icon: LogIn },
{ id: 'check-in' as ReceptionTab, label: 'Check-in', icon: LogIn },
{ id: 'check-out' as ReceptionTab, label: 'Check-out', icon: LogOut },
{ id: 'walk-in' as ReceptionTab, label: 'Walk-in Booking', icon: Plus },
{ id: 'bookings' as ReceptionTab, label: 'Bookings', icon: Calendar },
{ id: 'rooms' as ReceptionTab, label: 'Rooms', icon: Hotel },
{ id: 'services' as ReceptionTab, label: 'Services', icon: Wrench },
@@ -1947,6 +2103,271 @@ const ReceptionDashboardPage: React.FC = () => {
</div>
)}
{}
{activeTab === 'walk-in' && (
<div className="space-y-8">
{walkInLoading && (
<Loading fullScreen text="Creating walk-in booking..." />
)}
{}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-purple-500/10 to-pink-500/10 border border-purple-200/40">
<Plus className="w-6 h-6 text-purple-600" />
</div>
<h2 className="text-3xl font-extrabold text-gray-900">Walk-in Booking</h2>
</div>
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
Quick booking creation for walk-in guests
</p>
</div>
</div>
{}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{}
<div className="space-y-6">
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<span className="text-purple-600 font-bold">1</span>
</div>
Guest Information
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Full Name *</label>
<input
type="text"
value={walkInForm.guestName}
onChange={(e) => setWalkInForm({ ...walkInForm, guestName: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="Guest full name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Phone Number *</label>
<input
type="tel"
value={walkInForm.guestPhone}
onChange={(e) => setWalkInForm({ ...walkInForm, guestPhone: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="+1234567890"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
<input
type="email"
value={walkInForm.guestEmail}
onChange={(e) => setWalkInForm({ ...walkInForm, guestEmail: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="guest@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">ID Number</label>
<input
type="text"
value={walkInForm.guestIdNumber}
onChange={(e) => setWalkInForm({ ...walkInForm, guestIdNumber: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="ID/Passport number"
/>
</div>
</div>
</div>
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<span className="text-purple-600 font-bold">2</span>
</div>
Booking Details
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Check-in Date *</label>
<input
type="date"
value={walkInForm.checkInDate}
onChange={(e) => setWalkInForm({ ...walkInForm, checkInDate: e.target.value })}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Check-out Date *</label>
<input
type="date"
value={walkInForm.checkOutDate}
onChange={(e) => setWalkInForm({ ...walkInForm, checkOutDate: e.target.value })}
min={walkInForm.checkInDate || new Date().toISOString().split('T')[0]}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Number of Guests *</label>
<input
type="number"
min="1"
value={walkInForm.numGuests}
onChange={(e) => setWalkInForm({ ...walkInForm, numGuests: parseInt(e.target.value) || 1 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Children</label>
<input
type="number"
min="0"
value={walkInForm.numChildren}
onChange={(e) => setWalkInForm({ ...walkInForm, numChildren: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Special Requests</label>
<textarea
value={walkInForm.specialRequests}
onChange={(e) => setWalkInForm({ ...walkInForm, specialRequests: e.target.value })}
rows={3}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="Any special requests or notes..."
/>
</div>
<button
onClick={handleWalkInSearchRooms}
disabled={walkInSearchingRooms || !walkInForm.checkInDate || !walkInForm.checkOutDate}
className="w-full px-6 py-3.5 bg-gradient-to-r from-purple-500 to-pink-600 text-white font-semibold rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{walkInSearchingRooms ? 'Searching...' : 'Search Available Rooms'}
</button>
</div>
</div>
</div>
{}
<div className="space-y-6">
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<span className="text-purple-600 font-bold">3</span>
</div>
Select Room
</h3>
{walkInAvailableRooms.length === 0 ? (
<div className="text-center py-12">
<Hotel className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-500">Search for available rooms to see options</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{walkInAvailableRooms.map((room) => (
<div
key={room.id}
onClick={() => setWalkInForm({ ...walkInForm, selectedRoomId: room.id.toString() })}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
walkInForm.selectedRoomId === room.id.toString()
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300 hover:bg-purple-50/50'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-gray-900">Room {room.room_number}</div>
<div className="text-sm text-gray-600">{room.room_type?.name}</div>
<div className="text-sm font-semibold text-purple-600 mt-1">
{formatCurrency(room.room_type?.base_price || room.price || 0)}/night
</div>
</div>
{walkInForm.selectedRoomId === room.id.toString() && (
<CheckCircle className="w-6 h-6 text-purple-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
{walkInSelectedRoom && (
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<span className="text-purple-600 font-bold">4</span>
</div>
Payment & Summary
</h3>
<div className="space-y-4">
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Room:</span>
<span className="font-semibold">Room {walkInSelectedRoom.room_number}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Nights:</span>
<span className="font-semibold">
{Math.ceil(
(new Date(walkInForm.checkOutDate).getTime() -
new Date(walkInForm.checkInDate).getTime()) /
(1000 * 60 * 60 * 24)
)}
</span>
</div>
<div className="flex justify-between text-lg font-bold pt-2 border-t border-gray-200">
<span>Total:</span>
<span className="text-purple-600">{formatCurrency(walkInTotalPrice)}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Payment Method</label>
<select
value={walkInForm.paymentMethod}
onChange={(e) =>
setWalkInForm({ ...walkInForm, paymentMethod: e.target.value as 'cash' | 'stripe' })
}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
>
<option value="cash">Cash</option>
<option value="stripe">Card/Stripe</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Payment Status</label>
<select
value={walkInForm.paymentStatus}
onChange={(e) =>
setWalkInForm({ ...walkInForm, paymentStatus: e.target.value as 'unpaid' | 'deposit' | 'full' })
}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
>
<option value="unpaid">Unpaid</option>
<option value="deposit">Deposit</option>
<option value="full">Full Payment</option>
</select>
</div>
<button
onClick={handleWalkInBooking}
disabled={walkInLoading}
className="w-full px-6 py-4 bg-gradient-to-r from-purple-500 to-pink-600 text-white font-bold rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{walkInLoading ? 'Creating Booking...' : 'Create Walk-in Booking'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
{}
{activeTab === 'bookings' && (
<div className="space-y-8">