376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo } 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';
|
|
import { getUserFriendlyError } from '../../../shared/utils/errorSanitizer';
|
|
import { isAxiosError } from 'axios';
|
|
|
|
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);
|
|
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export const useRoomContext = () => {
|
|
const context = useContext(RoomContext);
|
|
if (!context) {
|
|
// Provide a more helpful error message with debugging info
|
|
const error = new Error(
|
|
'useRoomContext must be used within a RoomProvider. ' +
|
|
'Make sure RoomProvider wraps your component tree in App.tsx'
|
|
);
|
|
if (import.meta.env.DEV) {
|
|
console.error('RoomContext Error:', {
|
|
context,
|
|
stack: error.stack,
|
|
// Check if we're in a development environment
|
|
isDev: import.meta.env.DEV,
|
|
hint: 'This error usually occurs when: 1) Component is rendered outside RoomProvider, 2) Hot module reload issue, 3) Context provider not mounted yet',
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
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,
|
|
}, roomsAbortRef.current.signal);
|
|
|
|
if (response.data?.rooms) {
|
|
setRooms(response.data.rooms);
|
|
setLastUpdate(Date.now());
|
|
}
|
|
} catch (error: unknown) {
|
|
// Type guard for abort errors
|
|
const isAbortError = (e: unknown): boolean => {
|
|
if (typeof e === 'object' && e !== null) {
|
|
const err = e as { name?: string; code?: string; isCancelled?: boolean };
|
|
return err.name === 'AbortError' || err.code === 'ERR_CANCELED' || err.isCancelled === true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (isAbortError(error)) {
|
|
return;
|
|
}
|
|
logger.error('Error refreshing rooms', error);
|
|
setRoomsError(getUserFriendlyError(error) || '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,
|
|
statusBoardAbortRef.current.signal
|
|
);
|
|
|
|
if (response.status === 'success' && response.data?.rooms) {
|
|
setStatusBoardRooms(response.data.rooms);
|
|
setLastUpdate(Date.now());
|
|
}
|
|
} catch (error: unknown) {
|
|
// Type guard for abort errors
|
|
const isAbortError = (e: unknown): boolean => {
|
|
if (typeof e === 'object' && e !== null) {
|
|
const err = e as { name?: string; code?: string; isCancelled?: boolean };
|
|
return err.name === 'AbortError' || err.code === 'ERR_CANCELED' || err.isCancelled === true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (isAbortError(error)) {
|
|
return;
|
|
}
|
|
|
|
// Handle 401 Unauthorized gracefully - user may not have admin/staff role
|
|
if (isAxiosError(error) && 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(getUserFriendlyError(error) || '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: unknown) {
|
|
logger.error('Error updating room', error);
|
|
toast.error(getUserFriendlyError(error) || '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: unknown) {
|
|
logger.error('Error deleting room', error);
|
|
toast.error(getUserFriendlyError(error) || '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 Partial<Room> & { room_number: string; floor: number; room_type_id: number; status: 'available' | 'occupied' | 'maintenance' });
|
|
toast.success('Room created successfully');
|
|
|
|
// Refresh both views
|
|
await Promise.all([
|
|
refreshRooms(),
|
|
refreshStatusBoard(),
|
|
]);
|
|
} catch (error: unknown) {
|
|
logger.error('Error creating room', error);
|
|
toast.error(getUserFriendlyError(error) || '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
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []); // refreshRooms is stable (useCallback with empty deps), so this is safe
|
|
|
|
// 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(() => {
|
|
const autoRefreshInterval = autoRefreshIntervalRef.current;
|
|
return () => {
|
|
if (roomsAbortRef.current) {
|
|
roomsAbortRef.current.abort();
|
|
}
|
|
if (statusBoardAbortRef.current) {
|
|
statusBoardAbortRef.current.abort();
|
|
}
|
|
if (autoRefreshInterval) {
|
|
clearInterval(autoRefreshInterval);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const value: RoomContextType = useMemo(() => ({
|
|
rooms,
|
|
roomsLoading,
|
|
roomsError,
|
|
statusBoardRooms,
|
|
statusBoardLoading,
|
|
statusBoardError,
|
|
refreshRooms,
|
|
refreshStatusBoard,
|
|
updateRoom,
|
|
deleteRoom,
|
|
createRoom,
|
|
setRoomFilters,
|
|
roomFilters,
|
|
setRoomPage,
|
|
roomPage,
|
|
setStatusBoardFloor,
|
|
statusBoardFloor,
|
|
lastUpdate,
|
|
}), [
|
|
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>;
|
|
};
|
|
|