updates
This commit is contained in:
123
Frontend/src/pages/HousekeepingLayout.tsx
Normal file
123
Frontend/src/pages/HousekeepingLayout.tsx
Normal 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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
505
Frontend/src/pages/housekeeping/DashboardPage.tsx
Normal file
505
Frontend/src/pages/housekeeping/DashboardPage.tsx
Normal 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;
|
||||
|
||||
19
Frontend/src/pages/housekeeping/TasksPage.tsx
Normal file
19
Frontend/src/pages/housekeeping/TasksPage.tsx
Normal 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;
|
||||
|
||||
3
Frontend/src/pages/housekeeping/index.ts
Normal file
3
Frontend/src/pages/housekeeping/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
export { default as TasksPage } from './TasksPage';
|
||||
|
||||
Reference in New Issue
Block a user