// ==================================================================================
// OTO HUB - Main App (OOP: components/views/modals loaded from separate files)
// ==================================================================================
const { useState, useEffect, useRef } = React;
// Use components and utilities from loaded modules (window.*)
const ErrorBoundary = window.ErrorBoundary;
const TagsInput = window.TagsInput;
const validateKBForm = window.validateKBForm;
const VersionHistoryPanel = window.VersionHistoryPanel;
const AttachmentManager = window.AttachmentManager;
const ContactPrefIcons = window.ContactPrefIcons;
const PropertySelector = window.PropertySelector;
const MarkdownText = window.MarkdownText;
const HistoryVersionsButton = window.HistoryVersionsButton;
const RoleModal = window.RoleModal;
const ArticleModal = window.ArticleModal;
const PropertiesManagementModal = window.PropertiesManagementModal;
const AIChatView = window.AIChatView;
const ITSupportView = window.ITSupportView;
const AccessManagementView = window.AccessManagementView;
const ContactInformationView = window.ContactInformationView;
const VendorsView = window.VendorsView;
const VenuesView = window.VenuesView;
const AdministrationView = window.AdministrationView;
const formatDate = window.formatDate;
const getRelativeTime = window.getRelativeTime;
const formatPreferredContactMethods = window.formatPreferredContactMethods;
const getStatusColor = window.getStatusColor;
const sortPropertiesOTOThenAZ = window.sortPropertiesOTOThenAZ;
const normalizeKbMetadata = window.normalizeKbMetadata;
const getKbAudience = window.getKbAudience;
const getKbAttachments = window.getKbAttachments;
window.KBComponents = { TagsInput, validateKBForm, VersionHistoryPanel, AttachmentManager };
// Sidebar theme: one place to change blend, hover, and shadow. Exposed for PropertySelector (sidebar variant).
const OTOSidebarTheme = {
// Sidebar container – blends with gradient; no white tint
sidebarRoot: 'flex flex-col w-64 flex-shrink-0 border-r border-black/10 overflow-hidden',
sidebarHeader: 'px-5 pt-6 pb-4 border-b border-black/10',
sidebarNavLabel: 'text-blue-200/60 text-[11px] font-semibold mb-2.5 px-2 uppercase tracking-widest',
sidebarNavItem: 'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-all text-blue-100/95 hover:bg-black/15 hover:text-white',
sidebarNavItemActive: 'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-all bg-black/20 text-white shadow-md',
sidebarLogoutFooter: 'p-3 border-t border-black/10 bg-black/5',
sidebarLogoutButton: 'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium text-blue-100/90 hover:bg-black/15 hover:text-white transition-all flex items-center gap-2.5',
// Property card when used in sidebar (darker hover, medium shadow, blends)
propertyCard: 'bg-black/10 border border-black/20 rounded-xl p-4',
propertyLabel: 'text-white/70 text-[10px] font-semibold uppercase tracking-wider',
propertyBorder: 'border-black/20',
propertySelect: 'w-full bg-black/15 border border-black/25 text-white rounded-lg px-3 py-2.5 pr-8 text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-black/20 cursor-pointer appearance-none transition-all hover:border-black/40',
propertyButtonHover: 'hover:bg-black/15',
propertyDashedBorder: 'border-2 border-dashed border-black/20 rounded-lg',
};
if (typeof window !== 'undefined') window.OTOSidebarTheme = OTOSidebarTheme;
// Main App Component
const App = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [activeView, setActiveView] = useState('chat');
const [showCreateTicket, setShowCreateTicket] = useState(false);
const [ticketDescription, setTicketDescription] = useState('');
const [ticketDraft, setTicketDraft] = useState(null); // Store full AI draft
const [createTicketOrigin, setCreateTicketOrigin] = useState('it');
const [showPropertiesModal, setShowPropertiesModal] = useState(false);
const [currentUser, setCurrentUser] = useState({
FirstName: "Hotel",
LastName: "Rep",
Username: "hotelrep",
Email: "user@company.com",
Phone: "(555) 123-4567",
Role: "Front Desk",
properties: []
});
const [selectedProperty, setSelectedProperty] = useState(null);
const [pendingKbDraft, setPendingKbDraft] = useState(null); // KB article draft from AI for user to submit
const canManageProperties = window.canManageProperties ? window.canManageProperties(currentUser) : !!currentUser.can_manage_properties;
const canAccessPropertyManagement = window.canAccessPropertyManagement ? window.canAccessPropertyManagement(currentUser) : (!!currentUser?.can_manage_properties || !!currentUser?.can_manage_venues);
// Admin nav: show if user has Manage Settings, any KB permission, or View Audit Logs.
const isAdmin = window.canAccessAdmin ? window.canAccessAdmin(currentUser) : (
(currentUser?.can_manage_settings === true)
|| (currentUser?.can_manage_knowledge_base === true)
|| (currentUser?.can_approve_knowledge_base === true)
|| (currentUser?.can_view_knowledge_base === true)
|| (currentUser?.can_submit_knowledge_base === true)
|| (currentUser?.can_edit_knowledge_base === true)
|| (currentUser?.can_view_audit_logs === true)
);
const adminNavLabel = window.getAdminNavLabel ? window.getAdminNavLabel(currentUser) : (
(currentUser?.can_manage_settings === true) ? 'Administration' : (
(currentUser?.can_view_audit_logs === true && (currentUser?.can_manage_knowledge_base === true || currentUser?.can_view_knowledge_base === true)) ? 'Knowledge Base & Audit' :
(currentUser?.can_manage_knowledge_base === true || currentUser?.can_view_knowledge_base === true) ? 'Knowledge Base' :
(currentUser?.can_view_audit_logs === true) ? 'Audit' : 'Administration'
)
);
const canAccessAI = window.canAccessAI ? window.canAccessAI(currentUser) : !!(
currentUser?.can_access_general_ai || currentUser?.can_access_it_support || currentUser?.can_access_capex_support
);
// Listen for login events from other tabs to prevent duplicate portal tabs
useEffect(() => {
const handleStorageChange = (e) => {
// If another tab just logged in, close this tab if we're on the login page
if (e.key === 'oto_portal_login' && e.newValue && !isAuthenticated) {
try {
const loginData = JSON.parse(e.newValue);
// Check if this was a recent login (within last 2 seconds)
if (Date.now() - loginData.timestamp < 2000) {
// Another tab just logged in - close this one
window.close();
}
} catch (err) {
// Ignore parse errors
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [isAuthenticated]);
// Set selected property when user properties are available
// Prefer OTO if available, otherwise use first property
useEffect(() => {
if (currentUser.properties && currentUser.properties.length > 0 && !selectedProperty) {
// Sort properties: OTO first, then alphabetically
const sortedProps = [...currentUser.properties].sort((a, b) => {
if (a.property_code === 'OTO') return -1;
if (b.property_code === 'OTO') return 1;
return a.property_code.localeCompare(b.property_code);
});
setSelectedProperty(sortedProps[0]); // Now this will be OTO if user has it
}
}, [currentUser.properties]);
// AI Chat state is stored at the App level so it persists when switching views
// Default greeting is marked as system/display-only so it's not sent to backend; text is permission-based (see getAIAssistantGreeting).
const [chatMessages, setChatMessages] = useState([
{
role: 'assistant',
content:
(window.getAIAssistantGreeting && window.getAIAssistantGreeting(null)) ||
"Hi! I'm your OTO Assistant. I can help with property information, directory lookups, knowledge base articles, and general questions about OTO-HUB.\n\nHow can I help you today?",
isSystemMessage: true,
}
]);
const [chatAttachments, setChatAttachments] = useState([]);
const [chatAllAttachments, setChatAllAttachments] = useState([]);
const [ticketAttachments, setTicketAttachments] = useState([]);
const handleCreateTicketFromChat = (draft, attachments = []) => {
// draft contains: {subject, description, priority, category}
setTicketDraft(draft); // Store the full draft
setTicketDescription(draft.description || '');
setTicketAttachments(attachments);
setCreateTicketOrigin('chat'); // Remember we came from AI Assistant
setActiveView('it');
setShowCreateTicket(true);
};
const handleBackToChat = (isSubmitted = false) => {
setActiveView('chat');
setShowCreateTicket(false);
setTicketDescription('');
setTicketAttachments([]);
setCreateTicketOrigin('it');
if (isSubmitted) {
// Clear chat for new conversation after successful submission
setChatMessages([
{
role: 'assistant',
content:
(window.getAIAssistantGreeting && window.getAIAssistantGreeting(currentUser)) ||
"Hi! I'm your OTO Assistant. I can help with property information, directory lookups, knowledge base articles, and general questions about OTO-HUB.\n\nHow can I help you today?",
isSystemMessage: true,
}
]);
setChatAttachments([]);
setChatAllAttachments([]);
setTicketDraft(null);
}
// If cancelled (isSubmitted = false), keep context - don't clear anything
};
const handleShowCreateForm = (show, description = '') => {
if (show) {
setCreateTicketOrigin('it'); // Opened from IT Support
}
setShowCreateTicket(show);
setTicketDescription(description);
};
const handleLoginSuccess = async (user) => {
const username = user?.username || 'hotelrep';
let phoneNumber = '';
let preferredMethods = ['email'];
try {
const response = await fetch(`/api/contact/${username}`, {
headers: window.getApiHeaders ? window.getApiHeaders(username) : { 'X-OTO-User': username }
});
if (response.ok) {
const data = await response.json();
preferredMethods = Array.isArray(data.preferred_contact_methods) && data.preferred_contact_methods.length
? data.preferred_contact_methods
: ['email'];
if (data.phone) {
const cleaned = data.phone.replace(/\D/g, '');
if (cleaned.length === 10) {
phoneNumber = `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`;
} else {
phoneNumber = data.phone;
}
}
}
} catch (err) {
console.error('Failed to fetch contact info at login:', err);
}
const permissions = (window.applyPermissionsFromUser && window.applyPermissionsFromUser(user))
|| (user && Object.fromEntries(Object.keys(user).filter(k => k.startsWith('can_')).map(k => [k, !!user[k]])))
|| {};
setCurrentUser(prev => ({
...prev,
Email: user?.email || '',
FirstName: user?.first_name || 'Hotel',
LastName: user?.last_name || 'Rep',
Username: username,
Role: user?.role_display_name || user?.role_name || 'Front Desk',
Phone: phoneNumber,
PreferredContactMethods: preferredMethods,
properties: user?.properties || [],
...permissions,
}));
setActiveView('chat');
setIsAuthenticated(true);
try { localStorage.setItem('oto_username', username); } catch (e) { /* ignore */ }
// Clear chat on new login
setChatMessages([
{
role: 'assistant',
content:
(window.getAIAssistantGreeting && window.getAIAssistantGreeting(user)) ||
"Hi! I'm your OTO Assistant. I can help with property information, directory lookups, knowledge base articles, and general questions about OTO-HUB.\n\nHow can I help you today?",
isSystemMessage: true,
}
]);
setChatAttachments([]);
setChatAllAttachments([]);
};
// Refresh current user from API (role, permissions, properties). Used when role/staff updated elsewhere.
const refreshCurrentUser = React.useCallback(async () => {
const username = currentUser?.Username || (typeof localStorage !== 'undefined' && localStorage.getItem('oto_username'));
if (!username) return;
try {
const meRes = await fetch('/api/auth/me', {
headers: window.getApiHeaders ? window.getApiHeaders(username) : { 'X-OTO-User': username }
});
if (!meRes.ok) return;
const meData = await meRes.json();
const user = meData.user;
if (!user) return;
let phoneNumber = (currentUser && currentUser.Phone) || '';
let preferredMethods = (currentUser && currentUser.PreferredContactMethods) || ['email'];
try {
const contactRes = await fetch(`/api/contact/${username}`, {
headers: window.getApiHeaders ? window.getApiHeaders(username) : { 'X-OTO-User': username }
});
if (contactRes.ok) {
const contact = await contactRes.json();
preferredMethods = Array.isArray(contact.preferred_contact_methods) && contact.preferred_contact_methods.length
? contact.preferred_contact_methods : ['email'];
if (contact.phone) {
const cleaned = String(contact.phone).replace(/\D/g, '');
phoneNumber = cleaned.length === 10
? `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}` : contact.phone;
}
}
} catch (_) { /* keep existing phone/prefs */ }
const permissions = (window.applyPermissionsFromUser && window.applyPermissionsFromUser(user))
|| (user && Object.fromEntries(Object.keys(user).filter(k => k.startsWith('can_')).map(k => [k, !!user[k]])))
|| {};
setCurrentUser(prev => ({
...prev,
Email: user.email || prev.Email,
FirstName: user.first_name || prev.FirstName,
LastName: user.last_name || prev.LastName,
Username: user.username || username,
Role: user.role_display_name || user.role_name || prev.Role,
Phone: phoneNumber,
PreferredContactMethods: preferredMethods,
properties: user.properties || prev.properties || [],
...permissions,
}));
} catch (e) {
console.error('Refresh current user failed', e);
}
}, [currentUser?.Username, currentUser?.Phone, currentUser?.PreferredContactMethods]);
// Live updates: when role/staff/permissions change elsewhere, refresh app state
useEffect(() => {
if (!window.otoEvents || !isAuthenticated) return;
const unsub = window.otoEvents.on('current-user-updated', refreshCurrentUser);
return () => { if (typeof unsub === 'function') unsub(); };
}, [isAuthenticated, refreshCurrentUser]);
// When user returns to this tab (visibility), refresh current user so permission changes from another tab appear.
const wasHiddenRef = React.useRef(false);
useEffect(() => {
if (!isAuthenticated) return;
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') wasHiddenRef.current = true;
if (document.visibilityState === 'visible' && wasHiddenRef.current) {
wasHiddenRef.current = false;
refreshCurrentUser();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, [isAuthenticated, refreshCurrentUser]);
// Periodic refresh of permissions so role changes (e.g. by an admin) are reflected without leaving the tab
// Short interval so client UIs react quickly when their role/permissions change.
const PERMISSION_REFRESH_INTERVAL_MS = 15 * 1000;
useEffect(() => {
if (!isAuthenticated) return;
const interval = setInterval(refreshCurrentUser, PERMISSION_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [isAuthenticated, refreshCurrentUser]);
// When permissions change (e.g. after refresh or role update), force user out of any view they no longer have access to
useEffect(() => {
if (!isAuthenticated || !currentUser?.Username) return;
if (typeof window.isViewAllowed !== 'function' || typeof window.getFirstAllowedView !== 'function') return;
if (!window.isViewAllowed(activeView, currentUser)) {
const first = window.getFirstAllowedView(currentUser);
setActiveView(first);
setShowPropertiesModal(false);
if (window.otoNotify?.toast) {
window.otoNotify.toast('Your permissions have changed. You were moved to a different section.', 'info', { duration: 4000 });
}
}
}, [isAuthenticated, currentUser, activeView]);
const OTONotifyPortal = window.OTONotifyPortal;
if (!isAuthenticated) {
return (
<>
Signed in as
Navigation
You do not have access to the AI Assistant. Contact your administrator for General Information AI or IT Support permissions.
Sent to {email}
If you didn’t receive the email, the code may appear in server logs (e.g. Render logs).
OTO Development - OTO HUB
Can't login? Alternatively, you can contact
OTO IT Support at ITSupport@otodevelopment.com