This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -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>
{}

View File

@@ -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>;
};

View File

@@ -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;
},

View File

@@ -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,

View 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;

View 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';

View 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;

View File

@@ -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>
);
};

View File

@@ -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>{`

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>

View 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;

View File

@@ -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">
{}

File diff suppressed because it is too large Load Diff

View 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;

View File

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

View File

@@ -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">
{}

View File

@@ -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,

View 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;

View File

@@ -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}

View File

@@ -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) => {

View File

@@ -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'
},
]
},
];

View File

@@ -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) => {