updates
This commit is contained in:
@@ -121,6 +121,7 @@ const StaffShiftManagementPage = lazy(() => import('./pages/admin/StaffShiftMana
|
||||
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
|
||||
const StaffInventoryViewPage = lazy(() => import('./pages/staff/InventoryViewPage'));
|
||||
const StaffShiftViewPage = lazy(() => import('./pages/staff/ShiftViewPage'));
|
||||
const StaffTeamChatPage = lazy(() => import('./pages/staff/TeamChatPage'));
|
||||
const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage'));
|
||||
const StaffReceptionDashboardPage = lazy(() => import('./pages/staff/ReceptionDashboardPage'));
|
||||
const StaffPaymentManagementPage = lazy(() => import('./pages/staff/PaymentManagementPage'));
|
||||
@@ -132,6 +133,7 @@ const GuestRequestManagementPage = lazy(() => import('./pages/staff/GuestRequest
|
||||
const GuestCommunicationPage = lazy(() => import('./pages/staff/GuestCommunicationPage'));
|
||||
const IncidentComplaintManagementPage = lazy(() => import('./pages/staff/IncidentComplaintManagementPage'));
|
||||
const UpsellManagementPage = lazy(() => import('./pages/staff/UpsellManagementPage'));
|
||||
const StaffLoyaltyManagementPage = lazy(() => import('./pages/staff/LoyaltyManagementPage'));
|
||||
const StaffLayout = lazy(() => import('./pages/StaffLayout'));
|
||||
|
||||
const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardPage'));
|
||||
@@ -149,9 +151,12 @@ const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
|
||||
const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage'));
|
||||
const HousekeepingTasksPage = lazy(() => import('./pages/housekeeping/TasksPage'));
|
||||
const HousekeepingShiftViewPage = lazy(() => import('./pages/housekeeping/ShiftViewPage'));
|
||||
const HousekeepingProfilePage = lazy(() => import('./pages/housekeeping/ProfilePage'));
|
||||
const HousekeepingTeamChatPage = lazy(() => import('./pages/housekeeping/TeamChatPage'));
|
||||
const HousekeepingLayout = lazy(() => import('./pages/HousekeepingLayout'));
|
||||
|
||||
const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage'));
|
||||
const AdminTeamChatPage = lazy(() => import('./pages/admin/TeamChatPage'));
|
||||
const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage'));
|
||||
const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage'));
|
||||
|
||||
@@ -669,6 +674,10 @@ function App() {
|
||||
path="email-campaigns"
|
||||
element={<EmailCampaignManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="team-chat"
|
||||
element={<AdminTeamChatPage />}
|
||||
/>
|
||||
<Route
|
||||
path="reviews"
|
||||
element={<ReviewManagementPage />}
|
||||
@@ -815,10 +824,18 @@ function App() {
|
||||
path="inventory"
|
||||
element={<StaffInventoryViewPage />}
|
||||
/>
|
||||
<Route
|
||||
path="loyalty"
|
||||
element={<StaffLoyaltyManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="shifts"
|
||||
element={<StaffShiftViewPage />}
|
||||
/>
|
||||
<Route
|
||||
path="team-chat"
|
||||
element={<StaffTeamChatPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Accountant Routes */}
|
||||
@@ -905,7 +922,9 @@ function App() {
|
||||
<Route path="dashboard" element={<HousekeepingDashboardPage />} />
|
||||
<Route path="tasks" element={<HousekeepingTasksPage />} />
|
||||
<Route path="shifts" element={<HousekeepingShiftViewPage />} />
|
||||
<Route path="profile" element={<HousekeepingProfilePage />} />
|
||||
<Route path="sessions" element={<SessionManagementPage />} />
|
||||
<Route path="team-chat" element={<HousekeepingTeamChatPage />} />
|
||||
</Route>
|
||||
|
||||
{}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
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';
|
||||
@@ -41,7 +41,21 @@ 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');
|
||||
// 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;
|
||||
};
|
||||
@@ -95,14 +109,14 @@ export const RoomProvider: React.FC<RoomProviderProps> = ({ children }) => {
|
||||
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: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED' || error.isCancelled) {
|
||||
return;
|
||||
}
|
||||
logger.error('Error refreshing rooms', error);
|
||||
@@ -127,14 +141,17 @@ export const RoomProvider: React.FC<RoomProviderProps> = ({ children }) => {
|
||||
setStatusBoardLoading(true);
|
||||
setStatusBoardError(null);
|
||||
|
||||
const response = await advancedRoomService.getRoomStatusBoard(floor || statusBoardFloor || undefined);
|
||||
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: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED' || error.isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,7 +245,8 @@ export const RoomProvider: React.FC<RoomProviderProps> = ({ children }) => {
|
||||
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
|
||||
|
||||
@@ -290,7 +308,7 @@ export const RoomProvider: React.FC<RoomProviderProps> = ({ children }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: RoomContextType = {
|
||||
const value: RoomContextType = useMemo(() => ({
|
||||
rooms,
|
||||
roomsLoading,
|
||||
roomsError,
|
||||
@@ -309,7 +327,26 @@ export const RoomProvider: React.FC<RoomProviderProps> = ({ children }) => {
|
||||
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>;
|
||||
};
|
||||
|
||||
@@ -290,9 +290,10 @@ const advancedRoomService = {
|
||||
},
|
||||
|
||||
// Room Status Board
|
||||
async getRoomStatusBoard(floor?: number) {
|
||||
async getRoomStatusBoard(floor?: number, signal?: AbortSignal) {
|
||||
const response = await apiClient.get('/advanced-rooms/status-board', {
|
||||
params: floor ? { floor } : {}
|
||||
params: floor ? { floor } : {},
|
||||
signal,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -78,10 +78,12 @@ export const getFeaturedRooms = async (
|
||||
};
|
||||
|
||||
export const getRooms = async (
|
||||
params: RoomSearchParams = {}
|
||||
params: RoomSearchParams = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<RoomListResponse> => {
|
||||
const response = await apiClient.get('/rooms', {
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
const data = response.data;
|
||||
// Handle both 'status: success' and 'success: true' formats
|
||||
@@ -199,9 +201,28 @@ export const deleteAmenity = async (
|
||||
export interface RoomType {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
base_price: number;
|
||||
capacity: number;
|
||||
amenities?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoomTypeData {
|
||||
name: string;
|
||||
description?: string;
|
||||
base_price: number;
|
||||
capacity: number;
|
||||
amenities?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateRoomTypeData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
base_price?: number;
|
||||
capacity?: number;
|
||||
amenities?: string[];
|
||||
}
|
||||
|
||||
export const getRoomTypes = async (): Promise<{
|
||||
@@ -218,6 +239,58 @@ export const getRoomTypes = async (): Promise<{
|
||||
};
|
||||
};
|
||||
|
||||
export const getRoomType = async (id: number): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
data: { room_type: RoomType };
|
||||
message?: string;
|
||||
}> => {
|
||||
const response = await apiClient.get(`/rooms/room-types/${id}`);
|
||||
const data = response.data;
|
||||
return {
|
||||
success: data.status === 'success' || data.success === true,
|
||||
status: data.status,
|
||||
data: data.data || {},
|
||||
message: data.message || '',
|
||||
};
|
||||
};
|
||||
|
||||
export const createRoomType = async (
|
||||
data: CreateRoomTypeData
|
||||
): Promise<{ success: boolean; data: { room_type: RoomType }; message: string }> => {
|
||||
const response = await apiClient.post('/rooms/room-types', data);
|
||||
const responseData = response.data;
|
||||
return {
|
||||
success: responseData.status === 'success' || responseData.success === true,
|
||||
data: responseData.data || {},
|
||||
message: responseData.message || '',
|
||||
};
|
||||
};
|
||||
|
||||
export const updateRoomType = async (
|
||||
id: number,
|
||||
data: UpdateRoomTypeData
|
||||
): Promise<{ success: boolean; data: { room_type: RoomType }; message: string }> => {
|
||||
const response = await apiClient.put(`/rooms/room-types/${id}`, data);
|
||||
const responseData = response.data;
|
||||
return {
|
||||
success: responseData.status === 'success' || responseData.success === true,
|
||||
data: responseData.data || {},
|
||||
message: responseData.message || '',
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteRoomType = async (
|
||||
id: number
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.delete(`/rooms/room-types/${id}`);
|
||||
const data = response.data;
|
||||
return {
|
||||
success: data.status === 'success' || data.success === true,
|
||||
message: data.message || '',
|
||||
};
|
||||
};
|
||||
|
||||
export interface CreateRoomData {
|
||||
room_number: string;
|
||||
floor: number;
|
||||
@@ -294,6 +367,10 @@ export default {
|
||||
updateAmenity,
|
||||
deleteAmenity,
|
||||
getRoomTypes,
|
||||
getRoomType,
|
||||
createRoomType,
|
||||
updateRoomType,
|
||||
deleteRoomType,
|
||||
createRoom,
|
||||
updateRoom,
|
||||
deleteRoom,
|
||||
|
||||
848
Frontend/src/features/team-chat/components/TeamChatPage.tsx
Normal file
848
Frontend/src/features/team-chat/components/TeamChatPage.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Users,
|
||||
Hash,
|
||||
Plus,
|
||||
Send,
|
||||
MoreVertical,
|
||||
Search,
|
||||
Settings,
|
||||
User,
|
||||
Circle,
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
Edit2,
|
||||
Reply,
|
||||
Megaphone,
|
||||
ChevronDown,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import teamChatService, {
|
||||
TeamChannel,
|
||||
TeamMessage,
|
||||
TeamUser
|
||||
} from '../services/teamChatService';
|
||||
import useAuthStore from '../../../store/useAuthStore';
|
||||
import { normalizeImageUrl } from '../../../shared/utils/imageUtils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface TeamChatPageProps {
|
||||
role: 'admin' | 'staff' | 'housekeeping';
|
||||
}
|
||||
|
||||
const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const [channels, setChannels] = useState<TeamChannel[]>([]);
|
||||
const [selectedChannel, setSelectedChannel] = useState<TeamChannel | null>(null);
|
||||
const [messages, setMessages] = useState<TeamMessage[]>([]);
|
||||
const [teamUsers, setTeamUsers] = useState<TeamUser[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showNewChannelModal, setShowNewChannelModal] = useState(false);
|
||||
const [showNewDMModal, setShowNewDMModal] = useState(false);
|
||||
const [editingMessage, setEditingMessage] = useState<number | null>(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
|
||||
// Fetch channels
|
||||
const fetchChannels = useCallback(async () => {
|
||||
try {
|
||||
const response = await teamChatService.getMyChannels();
|
||||
if (response.success) {
|
||||
setChannels(response.data);
|
||||
// Auto-select first channel if none selected
|
||||
if (!selectedChannel && response.data.length > 0) {
|
||||
setSelectedChannel(response.data[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching channels:', error);
|
||||
}
|
||||
}, [selectedChannel]);
|
||||
|
||||
// Fetch team users
|
||||
const fetchTeamUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await teamChatService.getTeamUsers();
|
||||
if (response.success) {
|
||||
setTeamUsers(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching team users:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch messages for selected channel
|
||||
const fetchMessages = useCallback(async (channelId: number) => {
|
||||
try {
|
||||
const response = await teamChatService.getChannelMessages(channelId);
|
||||
if (response.success) {
|
||||
setMessages(response.data);
|
||||
// Scroll to bottom
|
||||
setTimeout(() => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize WebSocket
|
||||
useEffect(() => {
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/v1/team-chat/ws`;
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
// Send authentication
|
||||
socket.send(JSON.stringify({ type: 'auth', user_id: userInfo?.id }));
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'new_message' && data.data.channel_id === selectedChannel?.id) {
|
||||
setMessages(prev => [...prev, data.data]);
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
} else if (data.type === 'new_message_notification') {
|
||||
// Show notification for messages in other channels
|
||||
toast.info(`New message in ${data.data.channel_name || 'Team Chat'}`);
|
||||
fetchChannels(); // Refresh unread counts
|
||||
} else if (data.type === 'message_edited') {
|
||||
setMessages(prev => prev.map(m => m.id === data.data.id ? data.data : m));
|
||||
} else if (data.type === 'message_deleted') {
|
||||
setMessages(prev => prev.filter(m => m.id !== data.data.id));
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
setWs(socket);
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, [userInfo?.id, selectedChannel?.id, fetchChannels]);
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
await Promise.all([fetchChannels(), fetchTeamUsers()]);
|
||||
setIsLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [fetchChannels, fetchTeamUsers]);
|
||||
|
||||
// Load messages when channel changes
|
||||
useEffect(() => {
|
||||
if (selectedChannel) {
|
||||
fetchMessages(selectedChannel.id);
|
||||
// Join channel in WebSocket
|
||||
ws?.send(JSON.stringify({ type: 'join_channel', channel_id: selectedChannel.id }));
|
||||
}
|
||||
}, [selectedChannel, fetchMessages, ws]);
|
||||
|
||||
// Send message
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || !selectedChannel) return;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
const response = await teamChatService.sendMessage(selectedChannel.id, {
|
||||
content: newMessage.trim()
|
||||
});
|
||||
if (response.success) {
|
||||
setMessages(prev => [...prev, response.data]);
|
||||
setNewMessage('');
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
toast.error('Failed to send message');
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit message
|
||||
const handleEditMessage = async (messageId: number) => {
|
||||
if (!editContent.trim()) return;
|
||||
try {
|
||||
const response = await teamChatService.editMessage(messageId, editContent.trim());
|
||||
if (response.success) {
|
||||
setMessages(prev => prev.map(m => m.id === messageId ? response.data : m));
|
||||
setEditingMessage(null);
|
||||
setEditContent('');
|
||||
toast.success('Message edited');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to edit message');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete message
|
||||
const handleDeleteMessage = async (messageId: number) => {
|
||||
if (!confirm('Delete this message?')) return;
|
||||
try {
|
||||
await teamChatService.deleteMessage(messageId);
|
||||
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
toast.success('Message deleted');
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete message');
|
||||
}
|
||||
};
|
||||
|
||||
// Start DM
|
||||
const handleStartDM = async (userId: number) => {
|
||||
try {
|
||||
const response = await teamChatService.sendDirectMessage({
|
||||
recipient_id: userId,
|
||||
content: '👋 Hi!'
|
||||
});
|
||||
if (response.success) {
|
||||
await fetchChannels();
|
||||
const newChannel = channels.find(c => c.id === response.data.channel_id);
|
||||
if (newChannel) setSelectedChannel(newChannel);
|
||||
setShowNewDMModal(false);
|
||||
toast.success('Conversation started!');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to start conversation');
|
||||
}
|
||||
};
|
||||
|
||||
// Create channel
|
||||
const handleCreateChannel = async (name: string, memberIds: number[]) => {
|
||||
try {
|
||||
const response = await teamChatService.createChannel({
|
||||
name,
|
||||
channel_type: 'group',
|
||||
member_ids: memberIds
|
||||
});
|
||||
if (response.success) {
|
||||
await fetchChannels();
|
||||
setSelectedChannel(response.data);
|
||||
setShowNewChannelModal(false);
|
||||
toast.success('Channel created!');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to create channel');
|
||||
}
|
||||
};
|
||||
|
||||
// Get channel icon
|
||||
const getChannelIcon = (channel: TeamChannel) => {
|
||||
switch (channel.channel_type) {
|
||||
case 'direct':
|
||||
return <User className="w-4 h-4" />;
|
||||
case 'announcement':
|
||||
return <Megaphone className="w-4 h-4" />;
|
||||
case 'department':
|
||||
return <Users className="w-4 h-4" />;
|
||||
default:
|
||||
return <Hash className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter channels
|
||||
const filteredChannels = channels.filter(c =>
|
||||
c.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.members.some(m => m.full_name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
// Get role color
|
||||
const getRoleColor = (userRole: string | null) => {
|
||||
switch (userRole) {
|
||||
case 'admin': return 'bg-purple-100 text-purple-700';
|
||||
case 'staff': return 'bg-blue-100 text-blue-700';
|
||||
case 'housekeeping': return 'bg-amber-100 text-amber-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-green-500';
|
||||
case 'away': return 'bg-yellow-500';
|
||||
case 'busy': return 'bg-red-500';
|
||||
default: return 'bg-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-100px)]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading team chat...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200">
|
||||
{/* Sidebar - Channels List */}
|
||||
<div className="w-72 bg-gradient-to-b from-gray-900 to-gray-800 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-400" />
|
||||
Team Chat
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setShowNewDMModal(true)}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors text-gray-400 hover:text-white"
|
||||
title="New Direct Message"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewChannelModal(true)}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors text-gray-400 hover:text-white"
|
||||
title="New Channel"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search channels..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-sm text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channels List */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{/* Department Channels */}
|
||||
<div className="mb-4">
|
||||
<h3 className="px-2 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Channels
|
||||
</h3>
|
||||
{filteredChannels
|
||||
.filter(c => c.channel_type !== 'direct')
|
||||
.map(channel => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => setSelectedChannel(channel)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
selectedChannel?.id === channel.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{getChannelIcon(channel)}
|
||||
<span className="flex-1 truncate text-sm font-medium">{channel.name}</span>
|
||||
{channel.unread_count > 0 && (
|
||||
<span className="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{channel.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Direct Messages */}
|
||||
<div>
|
||||
<h3 className="px-2 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Direct Messages
|
||||
</h3>
|
||||
{filteredChannels
|
||||
.filter(c => c.channel_type === 'direct')
|
||||
.map(channel => {
|
||||
const otherMember = channel.members.find(m => m.id !== userInfo?.id);
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => setSelectedChannel(channel)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
selectedChannel?.id === channel.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
{otherMember?.avatar_url ? (
|
||||
<img
|
||||
src={normalizeImageUrl(otherMember.avatar_url)}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-gray-600 flex items-center justify-center">
|
||||
<User className="w-3 h-3 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<Circle className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 ${getStatusColor('online')} rounded-full border-2 border-gray-800`} fill="currentColor" />
|
||||
</div>
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{otherMember?.full_name || 'Unknown'}
|
||||
</span>
|
||||
{channel.unread_count > 0 && (
|
||||
<span className="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{channel.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Status */}
|
||||
<div className="p-3 border-t border-gray-700 bg-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
{userInfo?.avatar ? (
|
||||
<img
|
||||
src={normalizeImageUrl(userInfo.avatar)}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<Circle className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 ${getStatusColor('online')} rounded-full border-2 border-gray-800`} fill="currentColor" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{userInfo?.name}</p>
|
||||
<p className="text-xs text-gray-400 capitalize">{role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{selectedChannel ? (
|
||||
<>
|
||||
{/* Channel Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 bg-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
selectedChannel.channel_type === 'announcement'
|
||||
? 'bg-purple-100 text-purple-600'
|
||||
: selectedChannel.channel_type === 'department'
|
||||
? 'bg-green-100 text-green-600'
|
||||
: selectedChannel.channel_type === 'direct'
|
||||
? 'bg-blue-100 text-blue-600'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{getChannelIcon(selectedChannel)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{selectedChannel.name}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedChannel.members.length} members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<Users className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<Settings className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<MessageSquare className="w-12 h-12 mb-3 opacity-50" />
|
||||
<p>No messages yet</p>
|
||||
<p className="text-sm">Start the conversation!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => {
|
||||
const isOwn = message.sender_id === userInfo?.id;
|
||||
const showAvatar = index === 0 ||
|
||||
messages[index - 1]?.sender_id !== message.sender_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 group ${isOwn ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="w-8 flex-shrink-0">
|
||||
{showAvatar && (
|
||||
message.sender?.avatar_url ? (
|
||||
<img
|
||||
src={normalizeImageUrl(message.sender.avatar_url)}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div className={`max-w-[70%] ${isOwn ? 'items-end' : ''}`}>
|
||||
{showAvatar && (
|
||||
<div className={`flex items-center gap-2 mb-1 ${isOwn ? 'flex-row-reverse' : ''}`}>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{message.sender?.full_name || 'Unknown'}
|
||||
</span>
|
||||
{message.sender?.role && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${getRoleColor(message.sender.role)}`}>
|
||||
{message.sender.role}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatDistanceToNow(new Date(message.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingMessage === message.id ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleEditMessage(message.id)}
|
||||
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingMessage(null); setEditContent(''); }}
|
||||
className="p-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`relative group px-4 py-2 rounded-2xl ${
|
||||
isOwn
|
||||
? 'bg-blue-600 text-white rounded-br-md'
|
||||
: 'bg-white text-gray-900 border border-gray-200 rounded-bl-md'
|
||||
} ${message.priority === 'urgent' ? 'ring-2 ring-red-500' : ''}`}>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</p>
|
||||
{message.is_edited && (
|
||||
<span className={`text-xs ${isOwn ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||
(edited)
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Message Actions */}
|
||||
{isOwn && (
|
||||
<div className="absolute -top-2 right-0 hidden group-hover:flex gap-1 bg-white rounded-lg shadow-lg border border-gray-200 p-1">
|
||||
<button
|
||||
onClick={() => { setEditingMessage(message.id); setEditContent(message.content); }}
|
||||
className="p-1 hover:bg-gray-100 rounded text-gray-500"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteMessage(message.id)}
|
||||
className="p-1 hover:bg-red-100 rounded text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<form onSubmit={handleSendMessage} className="p-4 bg-white border-t border-gray-200">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder={`Message ${selectedChannel.name}...`}
|
||||
className="flex-1 px-4 py-3 bg-gray-100 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent focus:bg-white transition-colors"
|
||||
disabled={selectedChannel.channel_type === 'announcement' && role !== 'admin'}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newMessage.trim() || isSending || (selectedChannel.channel_type === 'announcement' && role !== 'admin')}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{selectedChannel.channel_type === 'announcement' && role !== 'admin' && (
|
||||
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Only admins can post in announcement channels
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<MessageSquare className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">Welcome to Team Chat</h3>
|
||||
<p className="text-gray-500 max-w-md">
|
||||
Select a channel or start a new conversation to communicate with your team.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => setShowNewChannelModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Channel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewDMModal(true)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
New Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Channel Modal */}
|
||||
{showNewChannelModal && (
|
||||
<NewChannelModal
|
||||
users={teamUsers}
|
||||
onClose={() => setShowNewChannelModal(false)}
|
||||
onCreate={handleCreateChannel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New DM Modal */}
|
||||
{showNewDMModal && (
|
||||
<NewDMModal
|
||||
users={teamUsers}
|
||||
onClose={() => setShowNewDMModal(false)}
|
||||
onSelect={handleStartDM}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// New Channel Modal Component
|
||||
const NewChannelModal: React.FC<{
|
||||
users: TeamUser[];
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, memberIds: number[]) => void;
|
||||
}> = ({ users, onClose, onCreate }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.full_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const toggleUser = (userId: number) => {
|
||||
setSelectedUsers(prev =>
|
||||
prev.includes(userId) ? prev.filter(id => id !== userId) : [...prev, userId]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl">
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Create Channel</h3>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Channel Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., morning-shift"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Add Members</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search team members..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 mb-2"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto border border-gray-200 rounded-lg">
|
||||
{filteredUsers.map(user => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => toggleUser(user.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 hover:bg-gray-50 transition-colors ${
|
||||
selectedUsers.includes(user.id) ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||
selectedUsers.includes(user.id) ? 'border-blue-600 bg-blue-600' : 'border-gray-300'
|
||||
}`}>
|
||||
{selectedUsers.includes(user.id) && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
{user.avatar_url ? (
|
||||
<img src={normalizeImageUrl(user.avatar_url)} alt="" className="w-8 h-8 rounded-full" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-medium">{user.full_name}</p>
|
||||
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCreate(name, selectedUsers)}
|
||||
disabled={!name.trim()}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// New DM Modal Component
|
||||
const NewDMModal: React.FC<{
|
||||
users: TeamUser[];
|
||||
onClose: () => void;
|
||||
onSelect: (userId: number) => void;
|
||||
}> = ({ users, onClose, onSelect }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.full_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const getRoleColor = (role: string | null) => {
|
||||
switch (role) {
|
||||
case 'admin': return 'bg-purple-100 text-purple-700';
|
||||
case 'staff': return 'bg-blue-100 text-blue-700';
|
||||
case 'housekeeping': return 'bg-amber-100 text-amber-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl">
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">New Message</h3>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search team members..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 mb-4"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">No team members found</p>
|
||||
) : (
|
||||
filteredUsers.map(user => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => onSelect(user.id)}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
{user.avatar_url ? (
|
||||
<img src={normalizeImageUrl(user.avatar_url)} alt="" className="w-10 h-10 rounded-full" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-gray-900">{user.full_name}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${getRoleColor(user.role)}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamChatPage;
|
||||
|
||||
12
Frontend/src/features/team-chat/index.ts
Normal file
12
Frontend/src/features/team-chat/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as TeamChatPage } from './components/TeamChatPage';
|
||||
export { default as teamChatService } from './services/teamChatService';
|
||||
export type {
|
||||
TeamChannel,
|
||||
TeamMessage,
|
||||
TeamUser,
|
||||
ChannelMember,
|
||||
CreateChannelData,
|
||||
SendMessageData,
|
||||
DirectMessageData
|
||||
} from './services/teamChatService';
|
||||
|
||||
167
Frontend/src/features/team-chat/services/teamChatService.ts
Normal file
167
Frontend/src/features/team-chat/services/teamChatService.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface TeamUser {
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
role: string | null;
|
||||
status?: string;
|
||||
last_seen?: string;
|
||||
}
|
||||
|
||||
export interface ChannelMember {
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
role: string | null;
|
||||
}
|
||||
|
||||
export interface TeamMessage {
|
||||
id: number;
|
||||
channel_id: number;
|
||||
sender_id: number;
|
||||
sender: TeamUser | null;
|
||||
content: string;
|
||||
priority: 'normal' | 'high' | 'urgent';
|
||||
reply_to_id: number | null;
|
||||
reference_type: string | null;
|
||||
reference_id: number | null;
|
||||
is_edited: boolean;
|
||||
is_deleted: boolean;
|
||||
created_at: string;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
export interface TeamChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
channel_type: 'direct' | 'group' | 'department' | 'announcement';
|
||||
department: string | null;
|
||||
is_private: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_message_at: string | null;
|
||||
unread_count: number;
|
||||
members: ChannelMember[];
|
||||
last_message: TeamMessage | null;
|
||||
}
|
||||
|
||||
export interface CreateChannelData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
channel_type?: 'group' | 'department' | 'announcement';
|
||||
department?: string;
|
||||
is_private?: boolean;
|
||||
member_ids?: number[];
|
||||
}
|
||||
|
||||
export interface SendMessageData {
|
||||
content: string;
|
||||
priority?: 'normal' | 'high' | 'urgent';
|
||||
reply_to_id?: number;
|
||||
reference_type?: string;
|
||||
reference_id?: number;
|
||||
}
|
||||
|
||||
export interface DirectMessageData {
|
||||
recipient_id: number;
|
||||
content: string;
|
||||
priority?: 'normal' | 'high' | 'urgent';
|
||||
}
|
||||
|
||||
class TeamChatService {
|
||||
// Channels
|
||||
async getMyChannels(): Promise<{ success: boolean; data: TeamChannel[] }> {
|
||||
const response = await apiClient.get('/team-chat/channels');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createChannel(data: CreateChannelData): Promise<{ success: boolean; data: TeamChannel }> {
|
||||
const response = await apiClient.post('/team-chat/channels', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getChannel(channelId: number): Promise<{ success: boolean; data: TeamChannel }> {
|
||||
const response = await apiClient.get(`/team-chat/channels/${channelId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateChannel(channelId: number, data: Partial<CreateChannelData>): Promise<{ success: boolean; data: TeamChannel }> {
|
||||
const response = await apiClient.put(`/team-chat/channels/${channelId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async addChannelMembers(channelId: number, memberIds: number[]): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.post(`/team-chat/channels/${channelId}/members`, memberIds);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async removeChannelMember(channelId: number, userId: number): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.delete(`/team-chat/channels/${channelId}/members/${userId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Messages
|
||||
async getChannelMessages(
|
||||
channelId: number,
|
||||
limit: number = 50,
|
||||
beforeId?: number
|
||||
): Promise<{ success: boolean; data: TeamMessage[] }> {
|
||||
const params = new URLSearchParams({ limit: limit.toString() });
|
||||
if (beforeId) params.append('before_id', beforeId.toString());
|
||||
const response = await apiClient.get(`/team-chat/channels/${channelId}/messages?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async sendMessage(channelId: number, data: SendMessageData): Promise<{ success: boolean; data: TeamMessage }> {
|
||||
const response = await apiClient.post(`/team-chat/channels/${channelId}/messages`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async editMessage(messageId: number, content: string): Promise<{ success: boolean; data: TeamMessage }> {
|
||||
const response = await apiClient.put(`/team-chat/messages/${messageId}`, { content });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteMessage(messageId: number): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.delete(`/team-chat/messages/${messageId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Direct Messages
|
||||
async sendDirectMessage(data: DirectMessageData): Promise<{ success: boolean; data: { channel_id: number; message: TeamMessage } }> {
|
||||
const response = await apiClient.post('/team-chat/direct', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Users
|
||||
async getTeamUsers(role?: string): Promise<{ success: boolean; data: TeamUser[] }> {
|
||||
const params = role ? `?role=${role}` : '';
|
||||
const response = await apiClient.get(`/team-chat/users${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Presence
|
||||
async updatePresence(status: 'online' | 'away' | 'busy' | 'offline', customStatus?: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.put('/team-chat/presence', { status, custom_status: customStatus });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Department Channels
|
||||
async initializeDepartmentChannels(): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.post('/team-chat/departments/init');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async joinDepartmentChannel(department: string): Promise<{ success: boolean; data: TeamChannel }> {
|
||||
const response = await apiClient.post(`/team-chat/departments/${department}/join`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const teamChatService = new TeamChatService();
|
||||
export default teamChatService;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import SidebarAccountant from '../shared/components/SidebarAccountant';
|
||||
import InAppNotificationBell from '../features/notifications/components/InAppNotificationBell';
|
||||
import { useResponsive } from '../shared/hooks/useResponsive';
|
||||
|
||||
const AccountantLayout: React.FC = () => {
|
||||
@@ -17,6 +18,11 @@ const AccountantLayout: React.FC = () => {
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification bell - fixed position in top right */}
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<InAppNotificationBell />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SidebarAdmin } from '../shared/components';
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
import { useResponsive } from '../hooks';
|
||||
import AIAssistantWidget from '../features/ai/components/AIAssistantWidget';
|
||||
import InAppNotificationBell from '../features/notifications/components/InAppNotificationBell';
|
||||
|
||||
// Luxury Loading Overlay
|
||||
const LuxuryLoadingOverlay: React.FC = () => {
|
||||
@@ -109,6 +110,11 @@ const AdminLayout: React.FC = () => {
|
||||
|
||||
{/* AI Assistant Widget */}
|
||||
<AIAssistantWidget />
|
||||
|
||||
{/* Notification bell - fixed position in top right */}
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<InAppNotificationBell />
|
||||
</div>
|
||||
|
||||
{/* Custom CSS for shimmer animation */}
|
||||
<style>{`
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Sparkles,
|
||||
LogOut,
|
||||
User,
|
||||
LayoutDashboard,
|
||||
ClipboardList,
|
||||
Calendar,
|
||||
Settings,
|
||||
Monitor,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import InAppNotificationBell from '../features/notifications/components/InAppNotificationBell';
|
||||
|
||||
const HousekeepingLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { userInfo, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -20,6 +28,17 @@ const HousekeepingLayout: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ path: '/housekeeping/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/housekeeping/tasks', label: 'Tasks', icon: ClipboardList },
|
||||
{ path: '/housekeeping/shifts', label: 'Shifts', icon: Calendar },
|
||||
{ path: '/housekeeping/team-chat', label: 'Team Chat', icon: MessageSquare },
|
||||
{ path: '/housekeeping/profile', label: 'Profile', icon: Settings },
|
||||
{ path: '/housekeeping/sessions', label: 'Sessions', icon: Monitor },
|
||||
];
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 overflow-x-hidden">
|
||||
{/* Luxury Top Navigation Bar */}
|
||||
@@ -27,35 +46,58 @@ const HousekeepingLayout: React.FC = () => {
|
||||
<div className="w-full max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-14 sm:h-16">
|
||||
{/* Logo/Brand */}
|
||||
<div className="flex items-center space-x-2 sm:space-x-3 min-w-0 flex-1">
|
||||
<div className="flex items-center space-x-2 sm:space-x-3 min-w-0 flex-shrink-0">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37] to-[#c9a227] rounded-lg blur-sm opacity-50"></div>
|
||||
<div className="relative bg-gradient-to-r from-[#d4af37] to-[#c9a227] p-1.5 sm:p-2 rounded-lg">
|
||||
<Sparkles className="w-4 h-4 sm:w-5 sm:h-5 md:w-6 md:h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-sm sm:text-base md:text-lg lg:text-xl font-serif font-bold text-gray-900 tracking-tight truncate">
|
||||
Enterprise Housekeeping
|
||||
<div className="min-w-0 hidden sm:block">
|
||||
<h1 className="text-sm sm:text-base md:text-lg font-serif font-bold text-gray-900 tracking-tight truncate">
|
||||
Housekeeping
|
||||
</h1>
|
||||
<p className="hidden xs:block text-[10px] sm:text-xs text-gray-500 font-light truncate">Luxury Management System</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex items-center space-x-1 sm:space-x-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center space-x-1 sm:space-x-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm font-medium transition-all duration-200 ${
|
||||
isActive(item.path)
|
||||
? 'bg-gradient-to-r from-[#d4af37]/10 to-[#c9a227]/10 text-[#d4af37] border border-[#d4af37]/30'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center space-x-2 sm:space-x-3 flex-shrink-0">
|
||||
<div className="hidden md:flex items-center space-x-2 lg:space-x-3 px-3 lg:px-4 py-1.5 lg:py-2 rounded-lg bg-gradient-to-r from-gray-50 to-gray-100/50 border border-gray-200/50">
|
||||
<div className="w-7 h-7 lg:w-8 lg:h-8 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-3.5 h-3.5 lg:w-4 lg:h-4 text-white" />
|
||||
{/* Notification Bell */}
|
||||
<InAppNotificationBell />
|
||||
|
||||
<div className="hidden lg:flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-gradient-to-r from-gray-50 to-gray-100/50 border border-gray-200/50">
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<div className="text-left min-w-0 hidden lg:block">
|
||||
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate max-w-[120px]">{userInfo?.name || userInfo?.email || 'User'}</p>
|
||||
<p className="text-[10px] lg:text-xs text-gray-500 capitalize truncate">{userInfo?.role || 'housekeeping'}</p>
|
||||
<div className="text-left min-w-0">
|
||||
<p className="text-xs font-medium text-gray-900 truncate max-w-[100px]">{userInfo?.name || userInfo?.email || 'User'}</p>
|
||||
<p className="text-[10px] text-gray-500 capitalize truncate">{userInfo?.role || 'housekeeping'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center justify-center space-x-1 sm:space-x-2 px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-all duration-200 border border-transparent hover:border-gray-200 flex-shrink-0"
|
||||
className="flex items-center justify-center space-x-1 sm:space-x-2 px-2 sm:px-3 py-1.5 sm:py-2 text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-all duration-200 border border-transparent hover:border-gray-200 flex-shrink-0"
|
||||
>
|
||||
<LogOut className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline text-xs sm:text-sm font-medium">Logout</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import SidebarStaff from '../shared/components/SidebarStaff';
|
||||
import StaffChatNotification from '../features/notifications/components/StaffChatNotification';
|
||||
import InAppNotificationBell from '../features/notifications/components/InAppNotificationBell';
|
||||
import { ChatNotificationProvider } from '../features/notifications/contexts/ChatNotificationContext';
|
||||
import { useResponsive } from '../shared/hooks/useResponsive';
|
||||
|
||||
@@ -21,6 +22,11 @@ const StaffLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification bell - fixed position in top right */}
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<InAppNotificationBell />
|
||||
</div>
|
||||
|
||||
{}
|
||||
<StaffChatNotification />
|
||||
</div>
|
||||
|
||||
@@ -20,24 +20,28 @@ import {
|
||||
X,
|
||||
Image as ImageIcon,
|
||||
Check,
|
||||
Bed,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import {
|
||||
RoomStatusBoardItem,
|
||||
} from '../../features/rooms/services/advancedRoomService';
|
||||
import roomService, { Room } from '../../features/rooms/services/roomService';
|
||||
import roomService, { Room, RoomType, CreateRoomTypeData } from '../../features/rooms/services/roomService';
|
||||
import MaintenanceManagement from '../../features/hotel_services/components/MaintenanceManagement';
|
||||
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
|
||||
import InspectionManagement from '../../features/hotel_services/components/InspectionManagement';
|
||||
import apiClient from '../../shared/services/apiClient';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { useRoomContext } from '../../features/rooms/contexts/RoomContext';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
|
||||
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections';
|
||||
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections' | 'room-types';
|
||||
|
||||
const AdvancedRoomManagementPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const {
|
||||
statusBoardRooms,
|
||||
statusBoardLoading,
|
||||
@@ -79,6 +83,21 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
const [customAmenityInput, setCustomAmenityInput] = useState('');
|
||||
const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null);
|
||||
|
||||
// Room Types management state
|
||||
const [roomTypesList, setRoomTypesList] = useState<RoomType[]>([]);
|
||||
const [roomTypesLoading, setRoomTypesLoading] = useState(false);
|
||||
const [showRoomTypeModal, setShowRoomTypeModal] = useState(false);
|
||||
const [editingRoomType, setEditingRoomType] = useState<RoomType | null>(null);
|
||||
const [roomTypeFormData, setRoomTypeFormData] = useState<CreateRoomTypeData>({
|
||||
name: '',
|
||||
description: '',
|
||||
base_price: 0,
|
||||
capacity: 1,
|
||||
amenities: [],
|
||||
});
|
||||
const [roomTypeSearchFilter, setRoomTypeSearchFilter] = useState('');
|
||||
const [customRoomTypeAmenityInput, setCustomRoomTypeAmenityInput] = useState('');
|
||||
|
||||
// Define fetchFloors before using it in useEffect
|
||||
const fetchFloors = useCallback(async () => {
|
||||
try {
|
||||
@@ -288,6 +307,134 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
}
|
||||
}, [editingRoom]);
|
||||
|
||||
// Helper function to ensure amenities is always an array
|
||||
const getAmenitiesArray = (amenities: any): string[] => {
|
||||
if (!amenities) return [];
|
||||
if (Array.isArray(amenities)) return amenities;
|
||||
if (typeof amenities === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(amenities);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Fetch room types for management tab
|
||||
const fetchRoomTypesList = useCallback(async () => {
|
||||
try {
|
||||
setRoomTypesLoading(true);
|
||||
const response = await roomService.getRoomTypes();
|
||||
if (response.data?.room_types) {
|
||||
setRoomTypesList(response.data.room_types);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to fetch room types', error);
|
||||
toast.error(error.response?.data?.detail || 'Unable to load room types');
|
||||
} finally {
|
||||
setRoomTypesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Room Type management functions
|
||||
const handleRoomTypeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingRoomType) {
|
||||
await roomService.updateRoomType(editingRoomType.id, roomTypeFormData);
|
||||
toast.success('Room type updated successfully');
|
||||
} else {
|
||||
await roomService.createRoomType(roomTypeFormData);
|
||||
toast.success('Room type created successfully');
|
||||
}
|
||||
setShowRoomTypeModal(false);
|
||||
resetRoomTypeForm();
|
||||
fetchRoomTypesList();
|
||||
fetchRoomTypes(); // Refresh dropdown list
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomTypeEdit = (roomType: RoomType) => {
|
||||
setEditingRoomType(roomType);
|
||||
setRoomTypeFormData({
|
||||
name: roomType.name,
|
||||
description: roomType.description || '',
|
||||
base_price: roomType.base_price,
|
||||
capacity: roomType.capacity,
|
||||
amenities: getAmenitiesArray(roomType.amenities),
|
||||
});
|
||||
setShowRoomTypeModal(true);
|
||||
};
|
||||
|
||||
const handleRoomTypeDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this room type? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await roomService.deleteRoomType(id);
|
||||
toast.success('Room type deleted successfully');
|
||||
fetchRoomTypesList();
|
||||
fetchRoomTypes(); // Refresh dropdown list
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Unable to delete room type');
|
||||
}
|
||||
};
|
||||
|
||||
const resetRoomTypeForm = () => {
|
||||
setEditingRoomType(null);
|
||||
setRoomTypeFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
base_price: 0,
|
||||
capacity: 1,
|
||||
amenities: [],
|
||||
});
|
||||
setCustomRoomTypeAmenityInput('');
|
||||
};
|
||||
|
||||
const toggleRoomTypeAmenity = (amenity: string) => {
|
||||
const currentAmenities = roomTypeFormData.amenities || [];
|
||||
if (currentAmenities.includes(amenity)) {
|
||||
setRoomTypeFormData({
|
||||
...roomTypeFormData,
|
||||
amenities: currentAmenities.filter(a => a !== amenity),
|
||||
});
|
||||
} else {
|
||||
setRoomTypeFormData({
|
||||
...roomTypeFormData,
|
||||
amenities: [...currentAmenities, amenity],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomRoomTypeAmenity = () => {
|
||||
if (customRoomTypeAmenityInput.trim() && !roomTypeFormData.amenities?.includes(customRoomTypeAmenityInput.trim())) {
|
||||
setRoomTypeFormData({
|
||||
...roomTypeFormData,
|
||||
amenities: [...(roomTypeFormData.amenities || []), customRoomTypeAmenityInput.trim()],
|
||||
});
|
||||
setCustomRoomTypeAmenityInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeRoomTypeAmenity = (amenity: string) => {
|
||||
setRoomTypeFormData({
|
||||
...roomTypeFormData,
|
||||
amenities: (roomTypeFormData.amenities || []).filter(a => a !== amenity),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'room-types') {
|
||||
fetchRoomTypesList();
|
||||
}
|
||||
}, [activeTab, fetchRoomTypesList]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const abortController = new AbortController();
|
||||
@@ -352,7 +499,8 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
capacity: roomFormData.capacity ? parseInt(roomFormData.capacity) : undefined,
|
||||
room_size: roomFormData.room_size || undefined,
|
||||
view: roomFormData.view || undefined,
|
||||
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
|
||||
// Don't send amenities - backend will inherit from room type
|
||||
amenities: [],
|
||||
};
|
||||
await contextUpdateRoom(editingRoom.id, updateData);
|
||||
|
||||
@@ -372,56 +520,74 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
capacity: roomFormData.capacity ? parseInt(roomFormData.capacity) : undefined,
|
||||
room_size: roomFormData.room_size || undefined,
|
||||
view: roomFormData.view || undefined,
|
||||
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
|
||||
// Don't send amenities - backend will inherit from room type
|
||||
amenities: [],
|
||||
};
|
||||
const response = await roomService.createRoom(createData);
|
||||
|
||||
if (response.data?.room) {
|
||||
if (selectedFiles.length > 0) {
|
||||
try {
|
||||
setUploadingImages(true);
|
||||
const uploadFormData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
uploadFormData.append('images', file);
|
||||
});
|
||||
|
||||
await apiClient.post(`/rooms/${response.data.room.id}/images`, uploadFormData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Images uploaded successfully');
|
||||
// Validate files before upload
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB per file
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
if (selectedFiles.length > MAX_FILES) {
|
||||
toast.error(`Maximum ${MAX_FILES} images allowed.`);
|
||||
setSelectedFiles([]);
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} catch (uploadError: any) {
|
||||
toast.error(uploadError.response?.data?.message || 'Room created but failed to upload images');
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} finally {
|
||||
setUploadingImages(false);
|
||||
} else {
|
||||
const invalidFiles = selectedFiles.filter(file =>
|
||||
!ALLOWED_TYPES.includes(file.type) || file.size > MAX_FILE_SIZE
|
||||
);
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error('Some files are invalid. Please select valid image files (max 5MB each).');
|
||||
setSelectedFiles([]);
|
||||
} else {
|
||||
try {
|
||||
setUploadingImages(true);
|
||||
const uploadFormData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
uploadFormData.append('images', file);
|
||||
});
|
||||
|
||||
await apiClient.post(`/rooms/${response.data.room.id}/images`, uploadFormData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Images uploaded successfully');
|
||||
setSelectedFiles([]);
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} catch (uploadError: any) {
|
||||
toast.error(uploadError.response?.data?.message || 'Room created but failed to upload images');
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} finally {
|
||||
setUploadingImages(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
}
|
||||
|
||||
setRoomFormData({
|
||||
room_number: response.data.room.room_number,
|
||||
floor: response.data.room.floor,
|
||||
room_type_id: response.data.room.room_type_id,
|
||||
status: response.data.room.status,
|
||||
featured: response.data.room.featured,
|
||||
price: response.data.room.price?.toString() || '',
|
||||
description: response.data.room.description || '',
|
||||
capacity: response.data.room.capacity?.toString() || '',
|
||||
room_size: response.data.room.room_size || '',
|
||||
view: response.data.room.view || '',
|
||||
amenities: response.data.room.amenities || [],
|
||||
});
|
||||
// Refresh context to sync the new room - don't call contextCreateRoom as it would duplicate
|
||||
await refreshRooms();
|
||||
await refreshStatusBoard();
|
||||
|
||||
await contextCreateRoom(createData);
|
||||
setShowRoomModal(false);
|
||||
resetRoomForm();
|
||||
toast.success('Room created successfully');
|
||||
return;
|
||||
|
||||
// Refresh context to sync the new room - don't call contextCreateRoom as it would duplicate
|
||||
await refreshRooms();
|
||||
await refreshStatusBoard();
|
||||
|
||||
setShowRoomModal(false);
|
||||
resetRoomForm();
|
||||
toast.success('Room created successfully');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -486,10 +652,14 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
|
||||
const resetRoomForm = () => {
|
||||
setEditingRoom(null);
|
||||
// Get default amenities from first room type if available
|
||||
const defaultRoomType = roomTypesList.length > 0 ? roomTypesList[0] : null;
|
||||
const defaultAmenities = defaultRoomType ? getAmenitiesArray(defaultRoomType.amenities) : [];
|
||||
|
||||
setRoomFormData({
|
||||
room_number: '',
|
||||
floor: 1,
|
||||
room_type_id: 1,
|
||||
room_type_id: roomTypes.length > 0 ? roomTypes[0].id : 1,
|
||||
status: 'available',
|
||||
featured: false,
|
||||
price: '',
|
||||
@@ -497,7 +667,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
capacity: '',
|
||||
room_size: '',
|
||||
view: '',
|
||||
amenities: [],
|
||||
amenities: defaultAmenities, // Inherit from default room type
|
||||
});
|
||||
setSelectedFiles([]);
|
||||
setUploadingImages(false);
|
||||
@@ -505,14 +675,8 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
setEditingAmenity(null);
|
||||
};
|
||||
|
||||
const toggleAmenity = (amenity: string) => {
|
||||
setRoomFormData(prev => ({
|
||||
...prev,
|
||||
amenities: prev.amenities.includes(amenity)
|
||||
? prev.amenities.filter(a => a !== amenity)
|
||||
: [...prev.amenities, amenity]
|
||||
}));
|
||||
};
|
||||
// Amenities are now managed only at the room type level
|
||||
// Rooms automatically inherit amenities from their room type
|
||||
|
||||
const handleAddCustomAmenity = () => {
|
||||
const trimmed = customAmenityInput.trim();
|
||||
@@ -595,15 +759,84 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
setSelectedFiles(files);
|
||||
if (!e.target.files || e.target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(e.target.files);
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB per file
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
// Check file count
|
||||
if (files.length > MAX_FILES) {
|
||||
toast.error(`Maximum ${MAX_FILES} images allowed. Please select fewer files.`);
|
||||
e.target.value = ''; // Reset input
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each file
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Check file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
errors.push(`${file.name}: Invalid file type. Only JPEG, PNG, WebP, and GIF images are allowed.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
errors.push(`${file.name}: File size exceeds ${MAX_FILE_SIZE / (1024 * 1024)}MB limit.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// Show errors if any
|
||||
if (errors.length > 0) {
|
||||
errors.forEach(error => toast.error(error));
|
||||
}
|
||||
|
||||
// Set valid files
|
||||
if (validFiles.length > 0) {
|
||||
setSelectedFiles(validFiles);
|
||||
if (validFiles.length < files.length) {
|
||||
toast.warning(`${validFiles.length} of ${files.length} files selected. Some files were rejected.`);
|
||||
}
|
||||
} else {
|
||||
setSelectedFiles([]);
|
||||
e.target.value = ''; // Reset input if no valid files
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadImages = async () => {
|
||||
if (!editingRoom || selectedFiles.length === 0) return;
|
||||
|
||||
// Validate before upload (double-check)
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB per file
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
if (selectedFiles.length > MAX_FILES) {
|
||||
toast.error(`Maximum ${MAX_FILES} images allowed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each file one more time before upload
|
||||
const invalidFiles = selectedFiles.filter(file =>
|
||||
!ALLOWED_TYPES.includes(file.type) || file.size > MAX_FILE_SIZE
|
||||
);
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error('Some files are invalid. Please select valid image files (max 5MB each).');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingImages(true);
|
||||
const formData = new FormData();
|
||||
@@ -712,6 +945,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: 'status-board' as Tab, label: 'Rooms & Status Board', icon: Hotel },
|
||||
{ id: 'room-types' as Tab, label: 'Room Types', icon: Bed },
|
||||
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
|
||||
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
|
||||
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
|
||||
@@ -955,6 +1189,148 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
{/* Inspections Tab */}
|
||||
{activeTab === 'inspections' && <InspectionManagement />}
|
||||
|
||||
{/* Room Types Tab */}
|
||||
{activeTab === 'room-types' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Room Type Management</h2>
|
||||
<p className="text-gray-600 mt-1">Manage room types, pricing, and amenities</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetRoomTypeForm();
|
||||
setShowRoomTypeModal(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg hover:from-blue-600 hover:to-blue-700 transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Room Type</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search room types..."
|
||||
value={roomTypeSearchFilter}
|
||||
onChange={(e) => setRoomTypeSearchFilter(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 border-2 border-gray-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room Types Table */}
|
||||
{roomTypesLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-gray-800 to-gray-900">
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Description</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Base Price</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Capacity</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Amenities</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-bold text-white uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{roomTypesList
|
||||
.filter(rt =>
|
||||
rt.name.toLowerCase().includes(roomTypeSearchFilter.toLowerCase()) ||
|
||||
(rt.description && rt.description.toLowerCase().includes(roomTypeSearchFilter.toLowerCase()))
|
||||
)
|
||||
.map((roomType) => {
|
||||
const amenities = getAmenitiesArray(roomType.amenities);
|
||||
return (
|
||||
<tr key={roomType.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Bed className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900">{roomType.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-600 max-w-md">
|
||||
{roomType.description || <span className="text-gray-400 italic">No description</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-bold text-emerald-600">
|
||||
{formatCurrency(roomType.base_price)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-gray-700">
|
||||
{roomType.capacity} {roomType.capacity === 1 ? 'guest' : 'guests'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1 max-w-md">
|
||||
{amenities.slice(0, 3).map((amenity, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-md"
|
||||
>
|
||||
{amenity}
|
||||
</span>
|
||||
))}
|
||||
{amenities.length > 3 && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-md">
|
||||
+{amenities.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
{amenities.length === 0 && (
|
||||
<span className="text-xs text-gray-400 italic">No amenities</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleRoomTypeEdit(roomType)}
|
||||
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRoomTypeDelete(roomType.id)}
|
||||
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{roomTypesList.filter(rt =>
|
||||
rt.name.toLowerCase().includes(roomTypeSearchFilter.toLowerCase()) ||
|
||||
(rt.description && rt.description.toLowerCase().includes(roomTypeSearchFilter.toLowerCase()))
|
||||
).length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Bed className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No room types found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Modal */}
|
||||
{showRoomModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-[100] p-4">
|
||||
@@ -1018,7 +1394,17 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
</label>
|
||||
<select
|
||||
value={roomFormData.room_type_id}
|
||||
onChange={(e) => setRoomFormData({ ...roomFormData, room_type_id: parseInt(e.target.value) })}
|
||||
onChange={(e) => {
|
||||
const selectedRoomTypeId = parseInt(e.target.value);
|
||||
// Find the selected room type and inherit its amenities
|
||||
const selectedRoomType = roomTypesList.find(rt => rt.id === selectedRoomTypeId);
|
||||
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
|
||||
setRoomFormData({
|
||||
...roomFormData,
|
||||
room_type_id: selectedRoomTypeId,
|
||||
amenities: inheritedAmenities // Inherit amenities from room type
|
||||
});
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300"
|
||||
required
|
||||
>
|
||||
@@ -1032,6 +1418,9 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
<option value="" className="bg-[#1a1a1a]">Loading...</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1 italic">
|
||||
Amenities are inherited from the selected room type. Manage amenities in the Room Types tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1140,53 +1529,47 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
Amenities & Features
|
||||
</h3>
|
||||
|
||||
<div className="border border-[#d4af37]/20 rounded-lg p-4 max-h-80 overflow-y-auto bg-[#0a0a0a]/50 backdrop-blur-sm">
|
||||
{availableAmenities.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">Loading amenities...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{availableAmenities.map((amenity) => {
|
||||
const isSelected = roomFormData.amenities.includes(amenity);
|
||||
return (
|
||||
<label
|
||||
key={amenity}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'bg-gradient-to-r from-[#d4af37]/20 to-[#c9a227]/20 border-2 border-[#d4af37] shadow-lg shadow-[#d4af37]/20'
|
||||
: 'bg-[#1a1a1a]/50 border-2 border-[#333] hover:border-[#d4af37]/30 hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleAmenity(amenity)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'bg-[#d4af37] border-[#d4af37] shadow-lg shadow-[#d4af37]/30'
|
||||
: 'border-gray-600 bg-transparent'
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-[#0f0f0f] font-bold" />}
|
||||
</div>
|
||||
<span className={`text-sm flex-1 transition-colors ${
|
||||
isSelected
|
||||
? 'font-semibold text-[#d4af37]'
|
||||
: 'text-gray-400 hover:text-gray-300'
|
||||
}`}>
|
||||
<div className="border border-[#d4af37]/20 rounded-lg p-4 bg-[#0a0a0a]/50 backdrop-blur-sm">
|
||||
{(() => {
|
||||
const selectedRoomType = roomTypesList.find(rt => rt.id === roomFormData.room_type_id);
|
||||
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
|
||||
|
||||
if (inheritedAmenities.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-sm text-gray-400 mb-2">No amenities defined for this room type.</p>
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
Go to the <strong>Room Types</strong> tab to add amenities to this room type.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-gray-400 italic flex items-center gap-2">
|
||||
<Check className="w-3 h-3 text-[#d4af37]" />
|
||||
Inherited from room type: <strong className="text-[#d4af37]">{selectedRoomType?.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{inheritedAmenities.map((amenity) => (
|
||||
<span
|
||||
key={amenity}
|
||||
className="px-3 py-1.5 bg-gradient-to-r from-[#d4af37]/20 to-[#c9a227]/20 border border-[#d4af37]/30 rounded-lg text-sm text-[#d4af37] font-medium"
|
||||
>
|
||||
{amenity}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3 italic">
|
||||
To modify amenities, edit the room type in the <strong>Room Types</strong> tab.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{roomFormData.amenities.length > 0 && (
|
||||
<p className="text-xs text-gray-400 font-light italic">
|
||||
{roomFormData.amenities.length} amenit{roomFormData.amenities.length === 1 ? 'y' : 'ies'} selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4 border-t border-[#d4af37]/20">
|
||||
@@ -1322,13 +1705,13 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Add New Images (max 5 images):
|
||||
Add New Images (max 5 images, 5MB each):
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="w-full text-sm text-gray-400
|
||||
@@ -1340,17 +1723,25 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
hover:file:border-[#d4af37] file:cursor-pointer
|
||||
transition-all duration-300 bg-[#0a0a0a] rounded-lg"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1 italic">
|
||||
Accepted formats: JPEG, PNG, WebP, GIF. Maximum 5MB per file.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-gray-400 font-light italic">
|
||||
{selectedFiles.length} file(s) selected
|
||||
{selectedFiles.length > 0 && (
|
||||
<span className="ml-2 text-xs">
|
||||
(Total: {(selectedFiles.reduce((sum, f) => sum + f.size, 0) / (1024 * 1024)).toFixed(2)}MB)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadImages}
|
||||
disabled={uploadingImages}
|
||||
disabled={uploadingImages || selectedFiles.length === 0}
|
||||
className="px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-semibold shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{uploadingImages ? 'Uploading...' : 'Upload Images'}
|
||||
@@ -1376,6 +1767,173 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Type Modal */}
|
||||
{showRoomTypeModal && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-500 to-blue-600 px-8 py-6 flex justify-between items-center rounded-t-2xl">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{editingRoomType ? 'Edit Room Type' : 'Create Room Type'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRoomTypeModal(false);
|
||||
resetRoomTypeForm();
|
||||
}}
|
||||
className="p-2 rounded-lg text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRoomTypeSubmit} className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={roomTypeFormData.name}
|
||||
onChange={(e) => setRoomTypeFormData({ ...roomTypeFormData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Base Price *</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={roomTypeFormData.base_price}
|
||||
onChange={(e) => setRoomTypeFormData({ ...roomTypeFormData, base_price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Capacity *</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="20"
|
||||
value={roomTypeFormData.capacity}
|
||||
onChange={(e) => setRoomTypeFormData({ ...roomTypeFormData, capacity: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Description</label>
|
||||
<textarea
|
||||
value={roomTypeFormData.description}
|
||||
onChange={(e) => setRoomTypeFormData({ ...roomTypeFormData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Amenities</h3>
|
||||
|
||||
{/* Available Amenities */}
|
||||
{availableAmenities.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Select Amenities</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableAmenities.map((amenity) => (
|
||||
<button
|
||||
key={amenity}
|
||||
type="button"
|
||||
onClick={() => toggleRoomTypeAmenity(amenity)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
roomTypeFormData.amenities?.includes(amenity)
|
||||
? 'bg-blue-500 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{amenity}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Amenity Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Add Custom Amenity</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customRoomTypeAmenityInput}
|
||||
onChange={(e) => setCustomRoomTypeAmenityInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addCustomRoomTypeAmenity();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter amenity name..."
|
||||
className="flex-1 px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomRoomTypeAmenity}
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded-xl font-semibold hover:bg-blue-600 transition-all"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Amenities */}
|
||||
{(roomTypeFormData.amenities || []).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Selected Amenities</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(roomTypeFormData.amenities || []).map((amenity, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-800 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{amenity}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRoomTypeAmenity(amenity)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowRoomTypeModal(false);
|
||||
resetRoomTypeForm();
|
||||
}}
|
||||
className="px-6 py-3 border-2 border-gray-300 text-gray-700 rounded-xl font-semibold hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-semibold hover:from-blue-600 hover:to-blue-700 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{editingRoomType ? 'Update Room Type' : 'Create Room Type'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,10 +17,7 @@ const EditRoomPage: React.FC = () => {
|
||||
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
|
||||
const [uploadingImages, setUploadingImages] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [customAmenityInput, setCustomAmenityInput] = useState('');
|
||||
const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null);
|
||||
const [availableAmenities, setAvailableAmenities] = useState<string[]>([]);
|
||||
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string; amenities?: string[] | string }>>([]);
|
||||
const [deletingImageUrl, setDeletingImageUrl] = useState<string | null>(null);
|
||||
const [failedImageUrls, setFailedImageUrls] = useState<Set<string>>(new Set());
|
||||
const [loadingImageUrls, setLoadingImageUrls] = useState<Set<string>>(new Set());
|
||||
@@ -40,13 +37,27 @@ const EditRoomPage: React.FC = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchRoomData();
|
||||
}
|
||||
fetchAvailableAmenities();
|
||||
fetchRoomTypes();
|
||||
const loadData = async () => {
|
||||
await fetchRoomTypes();
|
||||
if (id) {
|
||||
await fetchRoomData();
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
// Update amenities when room type changes
|
||||
useEffect(() => {
|
||||
if (formData.room_type_id && roomTypes.length > 0) {
|
||||
const selectedRoomType = roomTypes.find(rt => rt.id === formData.room_type_id);
|
||||
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
amenities: inheritedAmenities
|
||||
}));
|
||||
}
|
||||
}, [formData.room_type_id, roomTypes]);
|
||||
|
||||
// Reset image loading states when room data changes
|
||||
useEffect(() => {
|
||||
if (editingRoom) {
|
||||
@@ -85,56 +96,53 @@ const EditRoomPage: React.FC = () => {
|
||||
}
|
||||
}, [editingRoom?.id]);
|
||||
|
||||
// Helper function to ensure amenities is always an array
|
||||
const getAmenitiesArray = (amenities: any): string[] => {
|
||||
if (!amenities) return [];
|
||||
if (Array.isArray(amenities)) return amenities;
|
||||
if (typeof amenities === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(amenities);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
// Not JSON, treat as comma-separated
|
||||
return amenities.split(',').map((a: string) => a.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const fetchRoomTypes = async () => {
|
||||
try {
|
||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.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 (response.data.pagination && response.data.pagination.totalPages > 1) {
|
||||
const totalPages = response.data.pagination.totalPages;
|
||||
for (let page = 2; page <= totalPages; 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) {
|
||||
logger.error(`Failed to fetch page ${page}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
||||
const response = await roomService.getRoomTypes();
|
||||
if (response.data?.room_types) {
|
||||
setRoomTypes(response.data.room_types);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch room types', err);
|
||||
// Fallback to old method if getRoomTypes fails
|
||||
try {
|
||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.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 (allUniqueRoomTypes.size > 0) {
|
||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
logger.error('Failed to fetch room types (fallback)', fallbackErr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailableAmenities = async () => {
|
||||
try {
|
||||
const response = await roomService.getAmenities();
|
||||
if (response.data?.amenities) {
|
||||
setAvailableAmenities(response.data.amenities);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch amenities', error);
|
||||
}
|
||||
};
|
||||
// Amenities are now managed only at the room type level
|
||||
// No need to fetch available amenities separately
|
||||
|
||||
const fetchRoomData = async () => {
|
||||
if (!id) return null;
|
||||
@@ -162,6 +170,10 @@ const EditRoomPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Inherit amenities from room type when setting form data
|
||||
const roomType = roomTypes.find(rt => rt.id === room.room_type_id);
|
||||
const inheritedAmenities = roomType ? getAmenitiesArray(roomType.amenities) : amenitiesArray;
|
||||
|
||||
setFormData({
|
||||
room_number: room.room_number,
|
||||
floor: room.floor,
|
||||
@@ -173,7 +185,7 @@ const EditRoomPage: React.FC = () => {
|
||||
capacity: room.capacity?.toString() || '',
|
||||
room_size: room.room_size || '',
|
||||
view: room.view || '',
|
||||
amenities: amenitiesArray,
|
||||
amenities: inheritedAmenities, // Always use room type amenities
|
||||
});
|
||||
|
||||
return room;
|
||||
@@ -199,7 +211,8 @@ const EditRoomPage: React.FC = () => {
|
||||
capacity: formData.capacity ? parseInt(formData.capacity) : undefined,
|
||||
room_size: formData.room_size || undefined,
|
||||
view: formData.view || undefined,
|
||||
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
|
||||
// Don't send amenities - backend will inherit from room type
|
||||
amenities: [],
|
||||
};
|
||||
|
||||
await contextUpdateRoom(editingRoom.id, updateData);
|
||||
@@ -211,92 +224,8 @@ const EditRoomPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAmenity = (amenity: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
amenities: prev.amenities.includes(amenity)
|
||||
? prev.amenities.filter(a => a !== amenity)
|
||||
: [...prev.amenities, amenity]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddCustomAmenity = () => {
|
||||
const trimmed = customAmenityInput.trim();
|
||||
if (trimmed && !formData.amenities.includes(trimmed)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
amenities: [...prev.amenities, trimmed]
|
||||
}));
|
||||
setCustomAmenityInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAmenity = (amenity: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
amenities: prev.amenities.filter(a => a !== amenity)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditAmenity = (amenity: string) => {
|
||||
setEditingAmenity({ name: amenity, newName: amenity });
|
||||
};
|
||||
|
||||
const handleSaveAmenityEdit = async () => {
|
||||
if (!editingAmenity || editingAmenity.name === editingAmenity.newName.trim()) {
|
||||
setEditingAmenity(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newName = editingAmenity.newName.trim();
|
||||
if (!newName) {
|
||||
toast.error('Amenity name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await roomService.updateAmenity(editingAmenity.name, newName);
|
||||
toast.success(`Amenity "${editingAmenity.name}" updated to "${newName}"`);
|
||||
|
||||
setAvailableAmenities(prev => {
|
||||
const updated = prev.map(a => a === editingAmenity.name ? newName : a);
|
||||
return updated.sort();
|
||||
});
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
amenities: prev.amenities.map(a => a === editingAmenity.name ? newName : a)
|
||||
}));
|
||||
|
||||
setEditingAmenity(null);
|
||||
await fetchAvailableAmenities();
|
||||
await refreshRooms();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to update amenity');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAmenity = async (amenity: string) => {
|
||||
if (!window.confirm(`Are you sure you want to delete "${amenity}"? This will remove it from all rooms and room types.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await roomService.deleteAmenity(amenity);
|
||||
toast.success(`Amenity "${amenity}" deleted successfully`);
|
||||
|
||||
setAvailableAmenities(prev => prev.filter(a => a !== amenity));
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
amenities: prev.amenities.filter(a => a !== amenity)
|
||||
}));
|
||||
|
||||
await fetchAvailableAmenities();
|
||||
await refreshRooms();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to delete amenity');
|
||||
}
|
||||
};
|
||||
// Amenities are now managed only at the room type level
|
||||
// Rooms automatically inherit amenities from their room type
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
@@ -763,7 +692,17 @@ const EditRoomPage: React.FC = () => {
|
||||
</label>
|
||||
<select
|
||||
value={formData.room_type_id}
|
||||
onChange={(e) => setFormData({ ...formData, room_type_id: parseInt(e.target.value) })}
|
||||
onChange={(e) => {
|
||||
const selectedRoomTypeId = parseInt(e.target.value);
|
||||
// Find the selected room type and inherit its amenities
|
||||
const selectedRoomType = roomTypes.find(rt => rt.id === selectedRoomTypeId);
|
||||
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
|
||||
setFormData({
|
||||
...formData,
|
||||
room_type_id: selectedRoomTypeId,
|
||||
amenities: inheritedAmenities // Inherit amenities from room type
|
||||
});
|
||||
}}
|
||||
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg cursor-pointer appearance-none bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNiA5TDEyIDE1TDE4IDkiIHN0cm9rZT0iIzkyOUE1IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==')] bg-[length:20px] bg-[right_1rem_center] bg-no-repeat pr-12"
|
||||
required
|
||||
>
|
||||
@@ -913,184 +852,50 @@ const EditRoomPage: React.FC = () => {
|
||||
<h2 className="text-xl sm:text-2xl font-bold bg-gradient-to-r from-slate-900 to-slate-700 bg-clip-text text-transparent">
|
||||
Amenities & Features
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Customize room amenities and special features</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Amenities are inherited from the room type</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Amenity Input */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={customAmenityInput}
|
||||
onChange={(e) => setCustomAmenityInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCustomAmenity();
|
||||
}
|
||||
}}
|
||||
placeholder="Add custom amenity..."
|
||||
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomAmenity}
|
||||
disabled={!customAmenityInput.trim()}
|
||||
className="px-6 py-3.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-2xl hover:from-amber-600 hover:to-amber-700 transition-all duration-300 font-semibold shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transform hover:scale-105 disabled:hover:scale-100"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Amenities */}
|
||||
{formData.amenities.length > 0 && (
|
||||
<div className="p-5 bg-gradient-to-br from-amber-50/50 to-amber-100/30 rounded-2xl border-2 border-amber-200/50 shadow-lg">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-amber-600" />
|
||||
Selected Amenities ({formData.amenities.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{formData.amenities.map((amenity) => {
|
||||
const isCustom = !availableAmenities.includes(amenity);
|
||||
return (
|
||||
<div
|
||||
key={amenity}
|
||||
className="group flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-amber-100 to-amber-50 rounded-xl border border-amber-300 shadow-md hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<span className="text-sm font-semibold text-amber-800">{amenity}</span>
|
||||
{isCustom && (
|
||||
<span className="text-xs font-medium text-amber-700 bg-amber-200/50 px-2 py-0.5 rounded-lg">Custom</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAmenity(amenity)}
|
||||
className="ml-1 p-1 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-all duration-200"
|
||||
title="Remove amenity"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Amenities */}
|
||||
<div className="border-2 border-slate-200 rounded-2xl p-4 sm:p-6 max-h-96 overflow-y-auto bg-gradient-to-br from-slate-50/50 to-white shadow-inner custom-scrollbar">
|
||||
{availableAmenities.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 text-center py-8">Loading amenities...</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-amber-500" />
|
||||
Available Amenities
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-3">
|
||||
{availableAmenities.map((amenity) => {
|
||||
const isSelected = formData.amenities.includes(amenity);
|
||||
const isEditing = editingAmenity?.name === amenity;
|
||||
return (
|
||||
<div
|
||||
<div className="border-2 border-slate-200 rounded-2xl p-5 sm:p-6 bg-gradient-to-br from-slate-50/50 to-white shadow-inner">
|
||||
{(() => {
|
||||
const selectedRoomType = roomTypes.find(rt => rt.id === formData.room_type_id);
|
||||
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
|
||||
|
||||
if (inheritedAmenities.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-sm text-slate-500 mb-2">No amenities defined for this room type.</p>
|
||||
<p className="text-xs text-slate-400 italic">
|
||||
Go to the <strong>Room Types</strong> tab in Advanced Room Management to add amenities to this room type.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-slate-500 italic flex items-center gap-2">
|
||||
<Check className="w-3 h-3 text-amber-500" />
|
||||
Inherited from room type: <strong className="text-amber-600">{selectedRoomType?.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{inheritedAmenities.map((amenity) => (
|
||||
<span
|
||||
key={amenity}
|
||||
className={`flex items-center gap-3 p-4 rounded-2xl transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'bg-gradient-to-r from-amber-50 to-amber-100/50 border-2 border-amber-400 shadow-lg'
|
||||
: 'bg-white border-2 border-slate-200 hover:border-amber-300 hover:shadow-md'
|
||||
}`}
|
||||
className="px-4 py-2 bg-gradient-to-r from-amber-100 to-amber-50 border border-amber-300 rounded-xl text-sm font-semibold text-amber-800 shadow-sm"
|
||||
>
|
||||
<label className="flex items-center gap-3 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleAmenity(amenity)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className={`w-6 h-6 rounded-xl border-2 flex items-center justify-center transition-all duration-300 flex-shrink-0 shadow-md ${
|
||||
isSelected
|
||||
? 'bg-gradient-to-br from-amber-400 to-amber-600 border-amber-500 shadow-amber-200'
|
||||
: 'border-slate-300 bg-white'
|
||||
}`}>
|
||||
{isSelected && <Check className="w-4 h-4 text-white font-bold" />}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingAmenity.newName}
|
||||
onChange={(e) => setEditingAmenity({ ...editingAmenity, newName: e.target.value })}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveAmenityEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingAmenity(null);
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-white border-2 border-amber-400 rounded-xl text-slate-800 text-sm font-medium focus:ring-2 focus:ring-amber-300"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAmenityEdit}
|
||||
className="p-2 rounded-xl bg-green-50 text-green-600 hover:bg-green-100 transition-colors shadow-sm"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAmenity(null)}
|
||||
className="p-2 rounded-xl bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors shadow-sm"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className={`text-sm flex-1 font-medium transition-colors ${
|
||||
isSelected ? 'text-amber-800' : 'text-slate-700'
|
||||
}`}>
|
||||
{amenity}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleEditAmenity(amenity);
|
||||
}}
|
||||
className="p-2.5 rounded-xl bg-blue-50 border border-blue-200 text-blue-600 hover:bg-blue-100 hover:border-blue-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
title="Edit amenity"
|
||||
>
|
||||
<EditIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleDeleteAmenity(amenity);
|
||||
}}
|
||||
className="p-2.5 rounded-xl bg-red-50 border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
title="Delete amenity"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{amenity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-4 italic">
|
||||
To modify amenities, edit the room type in the <strong>Room Types</strong> tab.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
9
Frontend/src/pages/admin/TeamChatPage.tsx
Normal file
9
Frontend/src/pages/admin/TeamChatPage.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { TeamChatPage } from '../../features/team-chat';
|
||||
|
||||
const AdminTeamChatPage: React.FC = () => {
|
||||
return <TeamChatPage role="admin" />;
|
||||
};
|
||||
|
||||
export default AdminTeamChatPage;
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Award,
|
||||
Star,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dashboardService, { CustomerDashboardStats } from '../../features/analytics/services/dashboardService';
|
||||
import paymentService from '../../features/payments/services/paymentService';
|
||||
import loyaltyService from '../../features/loyalty/services/loyaltyService';
|
||||
import type { Payment } from '../../features/payments/services/paymentService';
|
||||
import sessionService, { UserSession } from '../../features/auth/services/sessionService';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -25,6 +28,13 @@ import { useAsync } from '../../shared/hooks/useAsync';
|
||||
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
interface LoyaltyInfo {
|
||||
available_points: number;
|
||||
tier_name: string;
|
||||
lifetime_points: number;
|
||||
next_tier_points_needed: number | null;
|
||||
}
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -32,6 +42,8 @@ const DashboardPage: React.FC = () => {
|
||||
const [loadingPayments, setLoadingPayments] = useState(false);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loyaltyInfo, setLoyaltyInfo] = useState<LoyaltyInfo | null>(null);
|
||||
const [loadingLoyalty, setLoadingLoyalty] = useState(false);
|
||||
|
||||
const fetchDashboardData = async (): Promise<CustomerDashboardStats> => {
|
||||
const response = await dashboardService.getCustomerDashboardStats();
|
||||
@@ -130,6 +142,47 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch loyalty info
|
||||
const loyaltyAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loyaltyAbortControllerRef.current) {
|
||||
loyaltyAbortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
loyaltyAbortControllerRef.current = new AbortController();
|
||||
|
||||
const fetchLoyalty = async () => {
|
||||
try {
|
||||
setLoadingLoyalty(true);
|
||||
const response = await loyaltyService.getMyLoyalty();
|
||||
if (response.status === 'success' && response.data) {
|
||||
setLoyaltyInfo({
|
||||
available_points: response.data.available_points || 0,
|
||||
tier_name: response.data.tier?.name || 'Bronze',
|
||||
lifetime_points: response.data.lifetime_points || 0,
|
||||
next_tier_points_needed: response.data.next_tier_points_needed,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
// Loyalty is optional, don't show error
|
||||
logger.debug('Loyalty info not available', err);
|
||||
}
|
||||
} finally {
|
||||
setLoadingLoyalty(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLoyalty();
|
||||
|
||||
return () => {
|
||||
if (loyaltyAbortControllerRef.current) {
|
||||
loyaltyAbortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
execute();
|
||||
};
|
||||
@@ -282,6 +335,40 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loyalty Points Card */}
|
||||
{loyaltyInfo && (
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-500 via-yellow-500 to-amber-500 rounded-xl sm:rounded-2xl shadow-xl p-4 sm:p-5 md:p-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in cursor-pointer hover:shadow-2xl transition-all duration-300"
|
||||
onClick={() => navigate('/loyalty')}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 sm:p-4 bg-white/20 backdrop-blur-sm rounded-xl sm:rounded-2xl">
|
||||
<Award className="w-8 h-8 sm:w-10 sm:h-10 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Star className="w-4 h-4 text-white" />
|
||||
<span className="text-white/80 text-sm font-medium uppercase tracking-wider">{loyaltyInfo.tier_name} Member</span>
|
||||
</div>
|
||||
<p className="text-3xl sm:text-4xl font-bold text-white">
|
||||
{loyaltyInfo.available_points.toLocaleString()} <span className="text-lg sm:text-xl font-medium">points</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<p className="text-white/80 text-sm">Lifetime Points: {loyaltyInfo.lifetime_points.toLocaleString()}</p>
|
||||
{loyaltyInfo.next_tier_points_needed && loyaltyInfo.next_tier_points_needed > 0 && (
|
||||
<p className="text-white/90 text-sm font-medium mt-1">
|
||||
{loyaltyInfo.next_tier_points_needed.toLocaleString()} points to next tier
|
||||
</p>
|
||||
)}
|
||||
<p className="text-white text-xs mt-2 hover:underline">View Rewards →</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
|
||||
{}
|
||||
|
||||
1134
Frontend/src/pages/housekeeping/ProfilePage.tsx
Normal file
1134
Frontend/src/pages/housekeeping/ProfilePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
9
Frontend/src/pages/housekeeping/TeamChatPage.tsx
Normal file
9
Frontend/src/pages/housekeeping/TeamChatPage.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { TeamChatPage } from '../../features/team-chat';
|
||||
|
||||
const HousekeepingTeamChatPage: React.FC = () => {
|
||||
return <TeamChatPage role="housekeeping" />;
|
||||
};
|
||||
|
||||
export default HousekeepingTeamChatPage;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
export { default as TasksPage } from './TasksPage';
|
||||
export { default as ProfilePage } from './ProfilePage';
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
CreditCard
|
||||
CreditCard,
|
||||
ClipboardCheck,
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import reportService, { ReportData } from '../../features/analytics/services/reportService';
|
||||
import paymentService from '../../features/payments/services/paymentService';
|
||||
@@ -164,8 +169,8 @@ const StaffDashboardPage: React.FC = () => {
|
||||
const getBookingStatusBadge = (status: string) => {
|
||||
const config = getBookingStatusConfig(status);
|
||||
return (
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
|
||||
{config.label}
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.color}`}>
|
||||
{config.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -314,6 +319,75 @@ const StaffDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-6 sm:mb-8 md:mb-10 animate-fade-in" style={{ animationDelay: '0.5s' }}>
|
||||
<h2 className="text-lg sm:text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-amber-500" />
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/staff/bookings')}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-white/90 backdrop-blur-md rounded-xl border border-slate-200 hover:border-blue-300 hover:bg-blue-50 hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<div className="p-3 bg-blue-100 rounded-xl">
|
||||
<ClipboardCheck className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700">Check-In/Out</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/staff/guest-profiles')}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-white/90 backdrop-blur-md rounded-xl border border-slate-200 hover:border-purple-300 hover:bg-purple-50 hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<div className="p-3 bg-purple-100 rounded-xl">
|
||||
<Users className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700">Guest Profiles</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/staff/guest-requests')}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-white/90 backdrop-blur-md rounded-xl border border-slate-200 hover:border-green-300 hover:bg-green-50 hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<div className="p-3 bg-green-100 rounded-xl">
|
||||
<MessageSquare className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700">Guest Requests</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/staff/advanced-rooms')}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-white/90 backdrop-blur-md rounded-xl border border-slate-200 hover:border-amber-300 hover:bg-amber-50 hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<div className="p-3 bg-amber-100 rounded-xl">
|
||||
<Hotel className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700">Room Status</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/staff/incidents-complaints')}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-white/90 backdrop-blur-md rounded-xl border border-slate-200 hover:border-rose-300 hover:bg-rose-50 hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<div className="p-3 bg-rose-100 rounded-xl">
|
||||
<AlertTriangle className="w-6 h-6 text-rose-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700">Complaints</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/staff/chats')}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-white/90 backdrop-blur-md rounded-xl border border-slate-200 hover:border-cyan-300 hover:bg-cyan-50 hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<div className="p-3 bg-cyan-100 rounded-xl">
|
||||
<MessageSquare className="w-6 h-6 text-cyan-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700">Live Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6">
|
||||
{}
|
||||
|
||||
@@ -157,8 +157,7 @@ const GuestCommunicationPage: React.FC = () => {
|
||||
setSending(true);
|
||||
|
||||
// Create guest communication record
|
||||
await apiClient.post('/guest-profiles/communications', {
|
||||
user_id: selectedGuest.id,
|
||||
await apiClient.post(`/guest-profiles/${selectedGuest.id}/communications`, {
|
||||
communication_type: communicationForm.communication_type,
|
||||
direction: 'outbound',
|
||||
subject: communicationForm.subject || undefined,
|
||||
|
||||
9
Frontend/src/pages/staff/TeamChatPage.tsx
Normal file
9
Frontend/src/pages/staff/TeamChatPage.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { TeamChatPage } from '../../features/team-chat';
|
||||
|
||||
const StaffTeamChatPage: React.FC = () => {
|
||||
return <TeamChatPage role="staff" />;
|
||||
};
|
||||
|
||||
export default StaffTeamChatPage;
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Star,
|
||||
Users,
|
||||
AlertCircle,
|
||||
Bell,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import { useCompanySettings } from '../contexts/CompanySettingsContext';
|
||||
@@ -137,6 +139,21 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</Link>
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && userInfo?.role !== 'housekeeping' && (
|
||||
<>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/favorites"
|
||||
onClick={() =>
|
||||
@@ -212,6 +229,36 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Complaints</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/guest-requests"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
<span>Guest Requests</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/gdpr"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Privacy & Data</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{userInfo?.role === 'admin' && (
|
||||
@@ -265,6 +312,23 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<span>Accountant Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
{userInfo?.role === 'housekeeping' && (
|
||||
<Link
|
||||
to="/housekeeping"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span>Housekeeping Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="border-t border-[#d4af37]/20 my-2"></div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
@@ -429,6 +493,18 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</Link>
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && userInfo?.role !== 'housekeeping' && (
|
||||
<>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Dashboard</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/favorites"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
@@ -489,6 +565,30 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Complaints</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/guest-requests"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Guest Requests</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/gdpr"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Privacy & Data</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{userInfo?.role === 'admin' && (
|
||||
@@ -539,6 +639,22 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<span className="font-light tracking-wide">Accountant Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
{userInfo?.role === 'housekeeping' && (
|
||||
<Link
|
||||
to="/housekeeping"
|
||||
onClick={() =>
|
||||
setIsUserMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-3 px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Housekeeping Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="border-t border-[#d4af37]/20 my-1"></div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
Shield,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
FileCheck
|
||||
FileCheck,
|
||||
Monitor
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useResponsive } from '../../hooks';
|
||||
@@ -134,6 +135,11 @@ const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
|
||||
icon: User,
|
||||
label: 'My Profile'
|
||||
},
|
||||
{
|
||||
path: '/accountant/sessions',
|
||||
icon: Monitor,
|
||||
label: 'Session Management'
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (path: string) => {
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
HardDrive,
|
||||
Activity,
|
||||
Calendar,
|
||||
Boxes
|
||||
Boxes,
|
||||
Monitor,
|
||||
CreditCard,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useResponsive } from '../../hooks';
|
||||
@@ -136,7 +139,12 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
{
|
||||
path: '/admin/advanced-rooms',
|
||||
icon: Hotel,
|
||||
label: 'Room Management'
|
||||
label: 'Rooms & Housekeeping'
|
||||
},
|
||||
{
|
||||
path: '/admin/services',
|
||||
icon: Activity,
|
||||
label: 'Services'
|
||||
},
|
||||
{
|
||||
path: '/admin/inventory',
|
||||
@@ -151,24 +159,23 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Business',
|
||||
title: 'Bookings & Finance',
|
||||
icon: TrendingUp,
|
||||
items: [
|
||||
{
|
||||
path: '/admin/business',
|
||||
icon: FileText,
|
||||
label: 'Business Dashboard'
|
||||
path: '/admin/bookings',
|
||||
icon: Calendar,
|
||||
label: 'All Bookings'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Analytics & Reports',
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{
|
||||
path: '/admin/analytics',
|
||||
icon: BarChart3,
|
||||
label: 'Analytics'
|
||||
path: '/admin/payments',
|
||||
icon: CreditCard,
|
||||
label: 'Payments'
|
||||
},
|
||||
{
|
||||
path: '/admin/invoices',
|
||||
icon: FileText,
|
||||
label: 'Invoices'
|
||||
},
|
||||
{
|
||||
path: '/admin/financial-audit',
|
||||
@@ -177,6 +184,22 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{
|
||||
path: '/admin/analytics',
|
||||
icon: BarChart3,
|
||||
label: 'Reports & Analytics'
|
||||
},
|
||||
{
|
||||
path: '/admin/business',
|
||||
icon: TrendingUp,
|
||||
label: 'Business Insights'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Users & Guests',
|
||||
icon: Users,
|
||||
@@ -235,6 +258,17 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Communication',
|
||||
icon: MessageSquare,
|
||||
items: [
|
||||
{
|
||||
path: '/admin/team-chat',
|
||||
icon: MessageSquare,
|
||||
label: 'Team Chat'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Content Management',
|
||||
icon: Globe,
|
||||
@@ -285,11 +319,6 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
icon: Settings,
|
||||
label: 'Settings'
|
||||
},
|
||||
{
|
||||
path: '/admin/compliance',
|
||||
icon: ClipboardCheck,
|
||||
label: 'Compliance'
|
||||
},
|
||||
{
|
||||
path: '/admin/approvals',
|
||||
icon: CheckCircle2,
|
||||
@@ -298,7 +327,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
{
|
||||
path: '/admin/gdpr',
|
||||
icon: Download,
|
||||
label: 'GDPR'
|
||||
label: 'GDPR & Compliance'
|
||||
},
|
||||
{
|
||||
path: '/admin/webhooks',
|
||||
@@ -315,11 +344,22 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
icon: HardDrive,
|
||||
label: 'Backups'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Account',
|
||||
icon: User,
|
||||
items: [
|
||||
{
|
||||
path: '/admin/profile',
|
||||
icon: User,
|
||||
label: 'My Profile'
|
||||
},
|
||||
{
|
||||
path: '/admin/sessions',
|
||||
icon: Monitor,
|
||||
label: 'Sessions'
|
||||
},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
X,
|
||||
CreditCard,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Award,
|
||||
Users,
|
||||
Wrench,
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
Package,
|
||||
Calendar
|
||||
Calendar,
|
||||
Monitor
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useChatNotifications } from '../../features/notifications/contexts/ChatNotificationContext';
|
||||
@@ -143,6 +145,11 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
|
||||
icon: Package,
|
||||
label: 'Inventory'
|
||||
},
|
||||
{
|
||||
path: '/staff/loyalty',
|
||||
icon: Award,
|
||||
label: 'Loyalty Program'
|
||||
},
|
||||
{
|
||||
path: '/staff/shifts',
|
||||
icon: Calendar,
|
||||
@@ -151,7 +158,12 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
|
||||
{
|
||||
path: '/staff/chats',
|
||||
icon: MessageCircle,
|
||||
label: 'Chat Support'
|
||||
label: 'Guest Chat'
|
||||
},
|
||||
{
|
||||
path: '/staff/team-chat',
|
||||
icon: MessageSquare,
|
||||
label: 'Team Chat'
|
||||
},
|
||||
{
|
||||
path: '/staff/reports',
|
||||
@@ -163,6 +175,11 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
|
||||
icon: Users,
|
||||
label: 'My Profile'
|
||||
},
|
||||
{
|
||||
path: '/staff/sessions',
|
||||
icon: Monitor,
|
||||
label: 'Session Management'
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (path: string) => {
|
||||
|
||||
Reference in New Issue
Block a user