This commit is contained in:
Iliyan Angelov
2025-12-01 15:34:45 +02:00
parent 49181cf48c
commit f7d6f24e49
28 changed files with 2121 additions and 832 deletions

View File

@@ -14,6 +14,7 @@ import { CurrencyProvider } from './features/payments/contexts/CurrencyContext';
import { CompanySettingsProvider } from './shared/contexts/CompanySettingsContext';
import { AuthModalProvider } from './features/auth/contexts/AuthModalContext';
import { AntibotProvider } from './features/auth/contexts/AntibotContext';
import { RoomProvider } from './features/rooms/contexts/RoomContext';
import { logDebug } from './shared/utils/errorReporter';
import OfflineIndicator from './shared/components/OfflineIndicator';
import CookieConsentBanner from './shared/components/CookieConsentBanner';
@@ -37,7 +38,8 @@ import {
AdminRoute,
StaffRoute,
AccountantRoute,
CustomerRoute
CustomerRoute,
HousekeepingRoute
} from './features/auth/components';
const HomePage = lazy(() => import('./features/content/pages/HomePage'));
@@ -122,6 +124,11 @@ const AccountantPaymentManagementPage = lazy(() => import('./pages/accountant/Pa
const AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage'));
const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage'));
const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage'));
const HousekeepingTasksPage = lazy(() => import('./pages/housekeeping/TasksPage'));
const HousekeepingLayout = lazy(() => import('./pages/HousekeepingLayout'));
const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage'));
const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage'));
const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage'));
@@ -210,6 +217,7 @@ function App() {
<CompanySettingsProvider>
<AntibotProvider>
<AuthModalProvider>
<RoomProvider>
<BrowserRouter
future={{
v7_startTransition: true,
@@ -709,6 +717,24 @@ function App() {
/>
</Route>
{/* Housekeeping Routes */}
<Route
path="/housekeeping"
element={
<ErrorBoundaryRoute>
<HousekeepingRoute>
<HousekeepingLayout />
</HousekeepingRoute>
</ErrorBoundaryRoute>
}
>
<Route
index
element={<Navigate to="dashboard" replace />}
/>
<Route path="dashboard" element={<HousekeepingDashboardPage />} />
</Route>
{}
<Route
path="*"
@@ -737,6 +763,7 @@ function App() {
<AuthModalManager />
</Suspense>
</BrowserRouter>
</RoomProvider>
</AuthModalProvider>
</AntibotProvider>
</CompanySettingsProvider>

View File

@@ -62,6 +62,8 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
return <Navigate to="/staff/dashboard" replace />;
} else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />;
} else if (userInfo?.role === 'housekeeping') {
return <Navigate to="/housekeeping/dashboard" replace />;
}
return <Navigate to="/" replace />;
}

View File

@@ -0,0 +1,52 @@
import React, { useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import useAuthStore from '../../../store/useAuthStore';
import { useAuthModal } from '../contexts/AuthModalContext';
interface HousekeepingRouteProps {
children: React.ReactNode;
}
const HousekeepingRoute: React.FC<HousekeepingRouteProps> = ({ children }) => {
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
const { openModal } = useAuthModal();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
openModal('login');
}
}, [isLoading, isAuthenticated, openModal]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto" />
<p className="mt-4 text-gray-600">Authenticating...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null; // Modal will be shown by AuthModalManager
}
// Only allow housekeeping role - no admin or staff access
if (userInfo?.role !== 'housekeeping') {
// Redirect to appropriate dashboard based on role
if (userInfo?.role === 'admin') {
return <Navigate to="/admin/dashboard" replace />;
} else if (userInfo?.role === 'staff') {
return <Navigate to="/staff/dashboard" replace />;
} else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />;
}
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
export default HousekeepingRoute;

View File

@@ -77,6 +77,8 @@ const LoginModal: React.FC = () => {
navigate('/staff/dashboard', { replace: true });
} else if (role === 'accountant') {
navigate('/accountant/dashboard', { replace: true });
} else if (role === 'housekeeping') {
navigate('/housekeeping/dashboard', { replace: true });
} else {
// Customer or default - go to customer dashboard
navigate('/dashboard', { replace: true });

View File

@@ -52,6 +52,8 @@ const StaffRoute: React.FC<StaffRouteProps> = ({
return <Navigate to="/admin/dashboard" replace />;
} else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />;
} else if (userInfo?.role === 'housekeeping') {
return <Navigate to="/housekeeping/dashboard" replace />;
}
return <Navigate to="/" replace />;
}

View File

@@ -3,4 +3,5 @@ export { default as AdminRoute } from './AdminRoute';
export { default as StaffRoute } from './StaffRoute';
export { default as AccountantRoute } from './AccountantRoute';
export { default as CustomerRoute } from './CustomerRoute';
export { default as HousekeepingRoute } from './HousekeepingRoute';
export { default as ResetPasswordRouteHandler } from './ResetPasswordRouteHandler';

View File

@@ -21,6 +21,7 @@ import 'react-datepicker/dist/react-datepicker.css';
const HousekeepingManagement: React.FC = () => {
const { userInfo } = useAuthStore();
const isAdmin = userInfo?.role === 'admin';
const isHousekeeping = userInfo?.role === 'housekeeping';
const [loading, setLoading] = useState(true);
const [tasks, setTasks] = useState<HousekeepingTask[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
@@ -227,8 +228,8 @@ const HousekeepingManagement: React.FC = () => {
}
toast.success('Housekeeping task updated successfully');
} else {
// Only admin can create tasks
if (!isAdmin) {
// Only admin and staff can create tasks
if (!isAdmin && userInfo?.role !== 'staff') {
toast.error('You do not have permission to create tasks');
return;
}
@@ -321,7 +322,7 @@ const HousekeepingManagement: React.FC = () => {
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{isAdmin && (
{(isAdmin || userInfo?.role === 'staff') && (
<button
onClick={handleCreate}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
@@ -399,8 +400,10 @@ const HousekeepingManagement: React.FC = () => {
<Edit className="w-4 h-4" />
</button>
) : (
// Staff can only edit their own assigned tasks
task.assigned_to === userInfo?.id && task.status !== 'completed' && (
// Housekeeping and staff can only edit their own assigned tasks
(isHousekeeping || userInfo?.role === 'staff') &&
task.assigned_to === userInfo?.id &&
task.status !== 'completed' && (
<>
<button
onClick={() => handleEdit(task)}

View File

@@ -0,0 +1,316 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import roomService, { Room } from '../services/roomService';
import advancedRoomService, { RoomStatusBoardItem } from '../services/advancedRoomService';
import { toast } from 'react-toastify';
import { logger } from '../../../shared/utils/logger';
interface RoomContextType {
// Room list state
rooms: Room[];
roomsLoading: boolean;
roomsError: string | null;
// Room status board state
statusBoardRooms: RoomStatusBoardItem[];
statusBoardLoading: boolean;
statusBoardError: string | null;
// Actions
refreshRooms: () => Promise<void>;
refreshStatusBoard: (floor?: number) => Promise<void>;
updateRoom: (roomId: number, updates: Partial<Room>) => Promise<void>;
deleteRoom: (roomId: number) => Promise<void>;
createRoom: (roomData: Partial<Room> & { room_number: string; floor: number; room_type_id: number; status: 'available' | 'occupied' | 'maintenance' }) => Promise<void>;
// Filters and pagination
setRoomFilters: (filters: { search?: string; status?: string; type?: string }) => void;
roomFilters: { search: string; status: string; type: string };
setRoomPage: (page: number) => void;
roomPage: number;
// Status board filters
setStatusBoardFloor: (floor: number | null) => void;
statusBoardFloor: number | null;
// Last update timestamp for synchronization
lastUpdate: number;
}
const RoomContext = createContext<RoomContextType | undefined>(undefined);
export const useRoomContext = () => {
const context = useContext(RoomContext);
if (!context) {
throw new Error('useRoomContext must be used within a RoomProvider');
}
return context;
};
interface RoomProviderProps {
children: React.ReactNode;
}
export const RoomProvider: React.FC<RoomProviderProps> = ({ children }) => {
// Room list state
const [rooms, setRooms] = useState<Room[]>([]);
const [roomsLoading, setRoomsLoading] = useState(false);
const [roomsError, setRoomsError] = useState<string | null>(null);
// Room status board state
const [statusBoardRooms, setStatusBoardRooms] = useState<RoomStatusBoardItem[]>([]);
const [statusBoardLoading, setStatusBoardLoading] = useState(false);
const [statusBoardError, setStatusBoardError] = useState<string | null>(null);
// Filters and pagination
const [roomFilters, setRoomFiltersState] = useState({ search: '', status: '', type: '' });
const [roomPage, setRoomPageState] = useState(1);
const [statusBoardFloor, setStatusBoardFloorState] = useState<number | null>(null);
// Last update timestamp
const [lastUpdate, setLastUpdate] = useState<number>(Date.now());
// Abort controllers for cleanup
const roomsAbortRef = useRef<AbortController | null>(null);
const statusBoardAbortRef = useRef<AbortController | null>(null);
// Auto-refresh interval (30 seconds)
const autoRefreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Refresh rooms list - fetch all rooms for better sync across components
const refreshRooms = useCallback(async () => {
// Cancel previous request
if (roomsAbortRef.current) {
roomsAbortRef.current.abort();
}
// Create new abort controller
roomsAbortRef.current = new AbortController();
try {
setRoomsLoading(true);
setRoomsError(null);
// Fetch rooms with reasonable pagination
// Individual components can handle their own pagination/filtering
const response = await roomService.getRooms({
limit: 100, // Reasonable batch size for sync
page: 1,
});
if (response.data?.rooms) {
setRooms(response.data.rooms);
setLastUpdate(Date.now());
}
} catch (error: any) {
if (error.name === 'AbortError') {
return;
}
logger.error('Error refreshing rooms', error);
setRoomsError(error.response?.data?.message || 'Failed to refresh rooms');
// Don't show toast on every auto-refresh, only on manual refresh
} finally {
setRoomsLoading(false);
}
}, []);
// Refresh status board
const refreshStatusBoard = useCallback(async (floor?: number) => {
// Cancel previous request
if (statusBoardAbortRef.current) {
statusBoardAbortRef.current.abort();
}
// Create new abort controller
statusBoardAbortRef.current = new AbortController();
try {
setStatusBoardLoading(true);
setStatusBoardError(null);
const response = await advancedRoomService.getRoomStatusBoard(floor || statusBoardFloor || undefined);
if (response.status === 'success' && response.data?.rooms) {
setStatusBoardRooms(response.data.rooms);
setLastUpdate(Date.now());
}
} catch (error: any) {
if (error.name === 'AbortError') {
return;
}
// Handle 401 Unauthorized gracefully - user may not have admin/staff role
if (error.response?.status === 401) {
setStatusBoardError(null); // Don't set error for unauthorized access
setStatusBoardRooms([]); // Clear status board if unauthorized
return; // Silently return without logging
}
logger.error('Error refreshing status board', error);
setStatusBoardError(error.response?.data?.detail || 'Failed to refresh status board');
// Don't show toast on every auto-refresh, only on manual refresh
} finally {
setStatusBoardLoading(false);
}
}, [statusBoardFloor]);
// Update room
const updateRoom = useCallback(async (roomId: number, updates: Partial<Room>) => {
try {
await roomService.updateRoom(roomId, updates);
toast.success('Room updated successfully');
// Refresh both views to ensure they're in sync
await Promise.all([
refreshRooms(),
refreshStatusBoard(),
]);
} catch (error: any) {
logger.error('Error updating room', error);
toast.error(error.response?.data?.message || 'Failed to update room');
throw error;
}
}, [refreshRooms, refreshStatusBoard]);
// Delete room
const deleteRoom = useCallback(async (roomId: number) => {
try {
await roomService.deleteRoom(roomId);
toast.success('Room deleted successfully');
// Refresh both views
await Promise.all([
refreshRooms(),
refreshStatusBoard(),
]);
} catch (error: any) {
logger.error('Error deleting room', error);
toast.error(error.response?.data?.message || 'Failed to delete room');
throw error;
}
}, [refreshRooms, refreshStatusBoard]);
// Create room
const createRoom = useCallback(async (roomData: Partial<Room> & { room_number: string; floor: number; room_type_id: number; status: 'available' | 'occupied' | 'maintenance' }) => {
try {
await roomService.createRoom(roomData as any);
toast.success('Room created successfully');
// Refresh both views
await Promise.all([
refreshRooms(),
refreshStatusBoard(),
]);
} catch (error: any) {
logger.error('Error creating room', error);
toast.error(error.response?.data?.message || 'Failed to create room');
throw error;
}
}, [refreshRooms, refreshStatusBoard]);
// Set room filters
const setRoomFilters = useCallback((filters: { search?: string; status?: string; type?: string }) => {
setRoomFiltersState(prev => ({ ...prev, ...filters }));
setRoomPageState(1); // Reset to first page when filters change
}, []);
// Set room page
const setRoomPage = useCallback((page: number) => {
setRoomPageState(page);
}, []);
// Set status board floor
const setStatusBoardFloor = useCallback((floor: number | null) => {
setStatusBoardFloorState(floor);
}, []);
// Initial load - only fetch rooms, status board will be fetched when needed
useEffect(() => {
refreshRooms();
// Don't fetch status board on initial load - it requires admin/staff role
// It will be fetched when the component that needs it mounts
}, []);
// Initial load and periodic refresh handled by auto-refresh interval
// Auto-refresh status board when floor changes (only if component using it is mounted)
// This will be called by AdvancedRoomManagementPage when floor filter changes
// Set up auto-refresh interval (every 60 seconds)
// Pause when tab is inactive to save resources
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null;
const startRefresh = () => {
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(() => {
if (!document.hidden) {
refreshRooms();
}
}, 60000); // 60 seconds
};
const handleVisibilityChange = () => {
if (document.hidden) {
// Pause refresh when tab is hidden
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
} else {
// Resume refresh when tab becomes visible
startRefresh();
// Immediate refresh when tab becomes visible
refreshRooms();
}
};
startRefresh();
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
if (intervalId) {
clearInterval(intervalId);
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [refreshRooms]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (roomsAbortRef.current) {
roomsAbortRef.current.abort();
}
if (statusBoardAbortRef.current) {
statusBoardAbortRef.current.abort();
}
if (autoRefreshIntervalRef.current) {
clearInterval(autoRefreshIntervalRef.current);
}
};
}, []);
const value: RoomContextType = {
rooms,
roomsLoading,
roomsError,
statusBoardRooms,
statusBoardLoading,
statusBoardError,
refreshRooms,
refreshStatusBoard,
updateRoom,
deleteRoom,
createRoom,
setRoomFilters,
roomFilters,
setRoomPage,
roomPage,
setStatusBoardFloor,
statusBoardFloor,
lastUpdate,
};
return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
};

View File

@@ -169,6 +169,28 @@ export const getAmenities = async (): Promise<{
};
};
export interface RoomType {
id: number;
name: string;
description: string;
base_price: number;
capacity: number;
}
export const getRoomTypes = async (): Promise<{
success: boolean;
status?: string;
data: { room_types: RoomType[] };
}> => {
const response = await apiClient.get('/rooms/room-types');
const data = response.data;
return {
success: data.status === 'success' || data.success === true,
status: data.status,
data: data.data || { room_types: [] },
};
};
export interface CreateRoomData {
room_number: string;
floor: number;
@@ -242,6 +264,7 @@ export default {
getRoomByNumber,
searchAvailableRooms,
getAmenities,
getRoomTypes,
createRoom,
updateRoom,
deleteRoom,

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
LogOut,
Menu,
X,
} from 'lucide-react';
import useAuthStore from '../store/useAuthStore';
import { useResponsive } from '../shared/hooks/useResponsive';
const HousekeepingLayout: React.FC = () => {
const { isMobile } = useResponsive();
const [sidebarOpen, setSidebarOpen] = React.useState(!isMobile);
const location = useLocation();
const navigate = useNavigate();
const { userInfo, logout } = useAuthStore();
const handleLogout = async () => {
try {
await logout();
navigate('/');
} catch (error) {
console.error('Logout error:', error);
}
};
const navigation = [
{ name: 'Dashboard', href: '/housekeeping/dashboard', icon: LayoutDashboard },
];
const isActive = (path: string) => location.pathname === path;
return (
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Mobile menu button */}
{isMobile && (
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="fixed top-4 left-4 z-50 p-2 bg-white rounded-lg shadow-lg"
>
{sidebarOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
)}
{/* Sidebar */}
<div
className={`
fixed lg:static inset-y-0 left-0 z-40
w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}
>
<div className="flex flex-col h-full">
{/* Logo/Brand */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center space-x-2">
<LayoutDashboard className="w-8 h-8 text-blue-600" />
<span className="text-xl font-bold text-gray-900">Housekeeping</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
{navigation.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.name}
to={item.href}
onClick={() => isMobile && setSidebarOpen(false)}
className={`
flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors
${isActive(item.href)
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-700 hover:bg-gray-50'
}
`}
>
<Icon className="w-5 h-5" />
<span>{item.name}</span>
</Link>
);
})}
</nav>
{/* User info and logout */}
<div className="p-4 border-t border-gray-200">
<div className="mb-3 px-4 py-2">
<p className="text-sm font-medium text-gray-900">{userInfo?.name || userInfo?.email || 'User'}</p>
<p className="text-xs text-gray-500 capitalize">{userInfo?.role || 'housekeeping'}</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center space-x-3 px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
<LogOut className="w-5 h-5" />
<span>Logout</span>
</button>
</div>
</div>
</div>
{/* Overlay for mobile */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-30"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<div className="flex-1 overflow-auto lg:ml-0">
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
<Outlet />
</div>
</div>
</div>
);
};
export default HousekeepingLayout;

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon, Check } from 'lucide-react';
import roomService, { Room } from '../../features/rooms/services/roomService';
import { toast } from 'react-toastify';
@@ -7,9 +7,22 @@ import Pagination from '../../shared/components/Pagination';
import apiClient from '../../shared/services/apiClient';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger';
import { useRoomContext } from '../../features/rooms/contexts/RoomContext';
const RoomManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const {
rooms: contextRooms,
roomsLoading,
refreshRooms,
updateRoom: contextUpdateRoom,
deleteRoom: contextDeleteRoom,
createRoom: contextCreateRoom,
setRoomFilters,
setRoomPage,
} = useRoomContext();
// Use context rooms, but filter/paginate locally for this page's display
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
@@ -43,7 +56,45 @@ const RoomManagementPage: React.FC = () => {
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
// Sync local filters with context
useEffect(() => {
setRoomFilters(filters);
}, [filters, setRoomFilters]);
// Sync local page with context
useEffect(() => {
setRoomPage(currentPage);
}, [currentPage, setRoomPage]);
// Update local rooms from context and apply local pagination
useEffect(() => {
if (contextRooms.length > 0) {
// Apply local filters
let filteredRooms = contextRooms.filter((room) => {
const matchesSearch = !filters.search ||
room.room_number.toLowerCase().includes(filters.search.toLowerCase()) ||
room.room_type?.name.toLowerCase().includes(filters.search.toLowerCase());
const matchesStatus = !filters.status || room.status === filters.status;
const matchesType = !filters.type || room.room_type?.name === filters.type;
return matchesSearch && matchesStatus && matchesType;
});
// Apply pagination
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedRooms = filteredRooms.slice(startIndex, endIndex);
setRooms(paginatedRooms);
setTotalPages(Math.ceil(filteredRooms.length / itemsPerPage));
setTotalItems(filteredRooms.length);
setLoading(false);
} else if (!roomsLoading) {
setLoading(false);
} else {
setLoading(roomsLoading);
}
}, [contextRooms, filters, currentPage, itemsPerPage, roomsLoading]);
useEffect(() => {
setCurrentPage(1);
@@ -51,24 +102,8 @@ const RoomManagementPage: React.FC = () => {
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchRooms();
fetchAvailableAmenities();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
}, []);
useEffect(() => {
@@ -132,23 +167,11 @@ const RoomManagementPage: React.FC = () => {
}
};
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);
}
// Extract unique room types from context rooms
useEffect(() => {
if (contextRooms.length > 0) {
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
response.data.rooms.forEach((room: Room) => {
contextRooms.forEach((room: Room) => {
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
uniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
@@ -157,60 +180,8 @@ const RoomManagementPage: React.FC = () => {
}
});
setRoomTypes(Array.from(uniqueRoomTypes.values()));
if (roomTypes.length === 0 && response.data.pagination) {
try {
const allRoomsResponse = await roomService.getRooms({ limit: 100, page: 1 });
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
allRoomsResponse.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
if (allRoomsResponse.data.pagination && allRoomsResponse.data.pagination.totalPages > 1) {
const totalPages = allRoomsResponse.data.pagination.totalPages;
for (let page = 2; page <= Math.min(totalPages, 10); page++) {
try {
const pageResponse = await roomService.getRooms({ limit: 100, page });
pageResponse.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
} catch (err) {
}
}
}
if (allUniqueRoomTypes.size > 0) {
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
}
} catch (err) {
}
}
} catch (error: any) {
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching rooms', error);
toast.error(error.response?.data?.message || 'Unable to load rooms list');
} finally {
setLoading(false);
}
};
}, [contextRooms]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -226,13 +197,9 @@ const RoomManagementPage: React.FC = () => {
view: formData.view || undefined,
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
};
await roomService.updateRoom(editingRoom.id, updateData);
toast.success('Room updated successfully');
await fetchRooms();
await contextUpdateRoom(editingRoom.id, updateData);
// Refresh room details for editing
try {
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
setEditingRoom(updatedRoom.data.room);
@@ -251,8 +218,6 @@ const RoomManagementPage: React.FC = () => {
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
};
const response = await roomService.createRoom(createData);
toast.success('Room added successfully');
if (response.data?.room) {
@@ -307,7 +272,7 @@ const RoomManagementPage: React.FC = () => {
});
await fetchRooms();
await contextCreateRoom(createData);
return;
}
@@ -315,7 +280,7 @@ const RoomManagementPage: React.FC = () => {
setShowModal(false);
resetForm();
fetchRooms();
await refreshRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
@@ -406,12 +371,10 @@ const RoomManagementPage: React.FC = () => {
if (!window.confirm('Are you sure you want to delete this room?')) return;
try {
await roomService.deleteRoom(id);
toast.success('Room deleted successfully');
await contextDeleteRoom(id);
setSelectedRooms(selectedRooms.filter(roomId => roomId !== id));
fetchRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete room');
// Error already handled in context
}
};
@@ -427,7 +390,7 @@ const RoomManagementPage: React.FC = () => {
await roomService.bulkDeleteRooms(selectedRooms);
toast.success(`Successfully deleted ${selectedRooms.length} room(s)`);
setSelectedRooms([]);
fetchRooms();
await refreshRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete rooms');
}
@@ -502,7 +465,7 @@ const RoomManagementPage: React.FC = () => {
toast.success('Images uploaded successfully');
setSelectedFiles([]);
fetchRooms();
await refreshRooms();
const response = await roomService.getRoomByNumber(editingRoom.room_number);
@@ -540,7 +503,7 @@ const RoomManagementPage: React.FC = () => {
});
toast.success('Image deleted successfully');
fetchRooms();
await refreshRooms();
const response = await roomService.getRoomByNumber(editingRoom.room_number);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
import userService, { User } from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
@@ -199,6 +199,18 @@ const UserManagementPage: React.FC = () => {
label: 'Staff',
border: 'border-blue-200'
},
accountant: {
bg: 'bg-gradient-to-r from-purple-50 to-violet-50',
text: 'text-purple-800',
label: 'Accountant',
border: 'border-purple-200'
},
housekeeping: {
bg: 'bg-gradient-to-r from-amber-50 to-orange-50',
text: 'text-amber-800',
label: 'Housekeeping',
border: 'border-amber-200'
},
customer: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
@@ -264,6 +276,8 @@ const UserManagementPage: React.FC = () => {
<option value="">All roles</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
<option value="accountant">Accountant</option>
<option value="housekeeping">Housekeeping</option>
<option value="customer">Customer</option>
</select>
<select
@@ -458,6 +472,7 @@ const UserManagementPage: React.FC = () => {
<option value="customer">Customer</option>
<option value="staff">Staff</option>
<option value="accountant">Accountant</option>
<option value="housekeeping">Housekeeping</option>
<option value="admin">Admin</option>
</select>
</div>

View File

@@ -0,0 +1,505 @@
import React, { useEffect, useState, useRef } from 'react';
import {
Sparkles,
Clock,
CheckCircle,
AlertCircle,
RefreshCw,
Calendar,
MapPin,
Play,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
import advancedRoomService, { HousekeepingTask, ChecklistItem } from '../../features/rooms/services/advancedRoomService';
import { logger } from '../../shared/utils/logger';
import useAuthStore from '../../store/useAuthStore';
const HousekeepingDashboardPage: React.FC = () => {
const { userInfo } = useAuthStore();
const [loading, setLoading] = useState(true);
const [tasks, setTasks] = useState<HousekeepingTask[]>([]);
const [stats, setStats] = useState({
pending: 0,
in_progress: 0,
completed: 0,
total: 0,
});
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
const [updatingTasks, setUpdatingTasks] = useState<Set<number>>(new Set());
const tasksAbortRef = useRef<AbortController | null>(null);
const fetchTasks = async () => {
try {
// Cancel previous request if exists
if (tasksAbortRef.current) {
tasksAbortRef.current.abort();
}
tasksAbortRef.current = new AbortController();
setLoading(true);
// Fetch today's tasks assigned to current user
const today = new Date().toISOString().split('T')[0];
const response = await advancedRoomService.getHousekeepingTasks({
date: today,
page: 1,
limit: 50,
});
if (response.status === 'success' && response.data?.tasks) {
const userTasks = response.data.tasks.filter(
(task: HousekeepingTask) => task.assigned_to === userInfo?.id
);
setTasks(userTasks);
// Calculate stats
const pending = userTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
const in_progress = userTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
const completed = userTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
setStats({
pending,
in_progress,
completed,
total: userTasks.length,
});
}
} catch (error: any) {
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching housekeeping tasks', error);
toast.error('Failed to load tasks');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
// Auto-refresh every 30 seconds
const interval = setInterval(() => {
if (document.visibilityState === 'visible') {
fetchTasks();
}
}, 30000);
return () => {
if (tasksAbortRef.current) {
tasksAbortRef.current.abort();
}
clearInterval(interval);
};
}, [userInfo?.id]);
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 border-green-200';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle className="w-5 h-5" />;
case 'in_progress':
return <Clock className="w-5 h-5" />;
case 'pending':
return <AlertCircle className="w-5 h-5" />;
default:
return <Clock className="w-5 h-5" />;
}
};
const toggleTaskExpansion = (taskId: number) => {
setExpandedTasks(prev => {
const newSet = new Set(prev);
if (newSet.has(taskId)) {
newSet.delete(taskId);
} else {
newSet.add(taskId);
}
return newSet;
});
};
const handleStartTask = async (task: HousekeepingTask) => {
if (updatingTasks.has(task.id)) return;
setUpdatingTasks(prev => new Set(prev).add(task.id));
try {
await advancedRoomService.updateHousekeepingTask(task.id, {
status: 'in_progress',
});
toast.success('Task started');
await fetchTasks();
} catch (error: any) {
logger.error('Error starting task', error);
toast.error(error.response?.data?.detail || 'Failed to start task');
} finally {
setUpdatingTasks(prev => {
const newSet = new Set(prev);
newSet.delete(task.id);
return newSet;
});
}
};
const handleUpdateChecklist = async (task: HousekeepingTask, itemIndex: number, checked: boolean) => {
if (updatingTasks.has(task.id)) return;
if (!task.checklist_items) return;
setUpdatingTasks(prev => new Set(prev).add(task.id));
try {
const updatedChecklist = [...task.checklist_items];
updatedChecklist[itemIndex] = {
...updatedChecklist[itemIndex],
completed: checked,
};
await advancedRoomService.updateHousekeepingTask(task.id, {
checklist_items: updatedChecklist,
});
// Update local state immediately for better UX
setTasks(prevTasks =>
prevTasks.map(t =>
t.id === task.id
? { ...t, checklist_items: updatedChecklist }
: t
)
);
// Recalculate stats
const updatedTask = { ...task, checklist_items: updatedChecklist };
const allTasks = tasks.map(t => t.id === task.id ? updatedTask : t);
const pending = allTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
const in_progress = allTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
const completed = allTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
setStats({ pending, in_progress, completed, total: allTasks.length });
} catch (error: any) {
logger.error('Error updating checklist', error);
toast.error(error.response?.data?.detail || 'Failed to update checklist');
} finally {
setUpdatingTasks(prev => {
const newSet = new Set(prev);
newSet.delete(task.id);
return newSet;
});
}
};
const handleCompleteTask = async (task: HousekeepingTask) => {
if (updatingTasks.has(task.id)) return;
// Check if all checklist items are completed
const allCompleted = task.checklist_items?.every(item => item.completed) ?? true;
if (!allCompleted && task.checklist_items && task.checklist_items.length > 0) {
const incomplete = task.checklist_items.filter(item => !item.completed).length;
if (!window.confirm(`You have ${incomplete} incomplete checklist item(s). Mark task as completed anyway?`)) {
return;
}
}
setUpdatingTasks(prev => new Set(prev).add(task.id));
try {
// Mark all checklist items as completed if not already
const updatedChecklist = task.checklist_items?.map(item => ({
...item,
completed: true,
})) || [];
await advancedRoomService.updateHousekeepingTask(task.id, {
status: 'completed',
checklist_items: updatedChecklist,
});
toast.success('Task marked as completed!');
await fetchTasks();
} catch (error: any) {
logger.error('Error completing task', error);
toast.error(error.response?.data?.detail || 'Failed to complete task');
} finally {
setUpdatingTasks(prev => {
const newSet = new Set(prev);
newSet.delete(task.id);
return newSet;
});
}
};
if (loading && tasks.length === 0) {
return <Loading />;
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Housekeeping Dashboard</h1>
<p className="mt-1 text-sm text-gray-500">
Welcome back, {userInfo?.name || userInfo?.email || 'Housekeeping Staff'}
</p>
</div>
<button
onClick={fetchTasks}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4" />
<span>Refresh</span>
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-yellow-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Pending</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.pending}</p>
</div>
<AlertCircle className="w-8 h-8 text-yellow-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">In Progress</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.in_progress}</p>
</div>
<Clock className="w-8 h-8 text-blue-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Completed</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.completed}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-indigo-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Today</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</p>
</div>
<Sparkles className="w-8 h-8 text-indigo-500" />
</div>
</div>
</div>
{/* Tasks List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Today's Tasks</h2>
</div>
{tasks.length === 0 ? (
<div className="p-12 text-center">
<Sparkles className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No tasks assigned</h3>
<p className="text-sm text-gray-500">You don't have any housekeeping tasks assigned for today.</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{tasks.map((task) => {
const completedItems = task.checklist_items?.filter(item => item.completed).length || 0;
const totalItems = task.checklist_items?.length || 0;
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
const isExpanded = expandedTasks.has(task.id);
const isUpdating = updatingTasks.has(task.id);
const canStart = task.status === 'pending';
const canComplete = task.status === 'in_progress' || task.status === 'pending';
return (
<div
key={task.id}
className="border-b border-gray-200 last:border-b-0"
>
<div className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900">
Room {task.room_number || task.room_id}
</h3>
<span className={`px-3 py-1 rounded-full text-xs font-medium border flex items-center space-x-1 ${getStatusColor(task.status)}`}>
{getStatusIcon(task.status)}
<span>{task.status.replace('_', ' ')}</span>
</span>
</div>
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-3">
<div className="flex items-center space-x-1">
<Calendar className="w-4 h-4" />
<span>{formatDate(task.scheduled_time)}</span>
</div>
<div className="flex items-center space-x-1">
<MapPin className="w-4 h-4" />
<span className="capitalize">{task.task_type}</span>
</div>
</div>
{task.checklist_items && task.checklist_items.length > 0 && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600">Progress</span>
<span className="text-sm font-medium text-gray-900">{progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{completedItems} of {totalItems} items completed
</p>
</div>
)}
</div>
<div className="flex items-center space-x-2 ml-4">
{canStart && (
<button
onClick={(e) => {
e.stopPropagation();
handleStartTask(task);
}}
disabled={isUpdating}
className="flex items-center space-x-1 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play className="w-4 h-4" />
<span>Start</span>
</button>
)}
{canComplete && (
<button
onClick={(e) => {
e.stopPropagation();
handleCompleteTask(task);
}}
disabled={isUpdating}
className="flex items-center space-x-1 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<CheckCircle className="w-4 h-4" />
<span>Complete</span>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
toggleTaskExpansion(task.id);
}}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)}
</button>
</div>
</div>
</div>
{/* Expanded Task Details */}
{isExpanded && (
<div className="px-6 pb-6 bg-gray-50 border-t border-gray-200">
<div className="pt-4 space-y-4">
{task.notes && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-1">Notes</h4>
<p className="text-sm text-gray-600">{task.notes}</p>
</div>
)}
{task.checklist_items && task.checklist_items.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-3">Checklist</h4>
<div className="space-y-2">
{task.checklist_items.map((item: ChecklistItem, index: number) => (
<label
key={index}
className="flex items-start space-x-3 p-3 bg-white rounded-lg border border-gray-200 hover:border-blue-300 transition-colors cursor-pointer"
>
<input
type="checkbox"
checked={item.completed}
onChange={(e) => {
e.stopPropagation();
handleUpdateChecklist(task, index, e.target.checked);
}}
disabled={isUpdating || task.status === 'completed'}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed"
/>
<div className="flex-1">
<span
className={`text-sm ${
item.completed
? 'text-gray-500 line-through'
: 'text-gray-900'
}`}
>
{item.item}
</span>
{item.notes && (
<p className="text-xs text-gray-500 mt-1">{item.notes}</p>
)}
</div>
{item.completed && (
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0" />
)}
</label>
))}
</div>
</div>
)}
{task.status === 'completed' && task.completed_at && (
<div className="pt-2 border-t border-gray-200">
<p className="text-xs text-gray-500">
Completed: {formatDate(task.completed_at)}
</p>
{task.actual_duration_minutes && (
<p className="text-xs text-gray-500 mt-1">
Duration: {task.actual_duration_minutes} minutes
</p>
)}
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
};
export default HousekeepingDashboardPage;

View File

@@ -0,0 +1,19 @@
import React from 'react';
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
const HousekeepingTasksPage: React.FC = () => {
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">My Housekeeping Tasks</h1>
<p className="mt-1 text-sm text-gray-500">
View and manage your assigned housekeeping tasks
</p>
</div>
<HousekeepingManagement />
</div>
);
};
export default HousekeepingTasksPage;

View File

@@ -0,0 +1,3 @@
export { default as DashboardPage } from './DashboardPage';
export { default as TasksPage } from './TasksPage';

View File

@@ -135,7 +135,7 @@ const Header: React.FC<HeaderProps> = ({
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && (
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && userInfo?.role !== 'housekeeping' && (
<>
<Link
to="/favorites"
@@ -427,7 +427,7 @@ const Header: React.FC<HeaderProps> = ({
<User className="w-4 h-4" />
<span className="font-light tracking-wide">Profile</span>
</Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && (
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && userInfo?.role !== 'housekeeping' && (
<>
<Link
to="/favorites"