Files
doormile_hub_console/src/layout/MainLayout/Header.jsx

313 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Settings, LogOut, Bell, MessageSquare, Search, Truck, AlertTriangle, ArrowRight, Send, CheckCircle2 } from 'lucide-react';
import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Avatar } from '@astryxdesign/core/Avatar';
import Button from '@/components/Button';
import Logo from '@/components/Logo';
import { getStaff, getHubContext, clearSession } from '@/auth/session';
import {
getNotifications,
markNotificationRead,
getConversations,
getConversation,
sendMessage,
markConversationRead
} from '@/api/hub';
const RED = 'var(--color-brand)';
const NOTIF_ICON = {
exception: AlertTriangle,
inbound: Truck,
dispatch: Truck,
warning: AlertTriangle,
alert: Bell
};
const toInitials = (name) =>
(name || '')
.split(' ')
.filter(Boolean)
.map((w) => w[0])
.slice(0, 2)
.join('')
.toUpperCase() || 'HB';
const toConversation = (c) => {
const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`;
return {
id: c.id ?? c.conversationid ?? c.mileruserid,
name,
initials: toInitials(name),
lastMessage: c.lastmessage ?? c.last_message ?? c.preview ?? '',
time: c.time ?? c.lasttime ?? c.updatedat ?? '',
unread: Number(c.unread ?? c.unreadcount ?? 0) || 0
};
};
const toMessage = (m) => ({
sender: m.sender === 'me' ? 'me' : 'them',
text: m.text ?? m.body ?? m.message ?? '',
time: m.time ?? m.createdat ?? m.sentat ?? ''
});
export default function Header({ isSidebarCollapsed }) {
const navigate = useNavigate();
const staff = getStaff();
const hub = getHubContext();
const staffName = staff.displayname || 'Hub Staff';
const hubName = hub.hubname || 'Doormile Hub';
const handleLogout = () => {
clearSession();
navigate('/login');
};
const [selectedNotif, setSelectedNotif] = useState(null);
const [conversations, setConversations] = useState([]);
const [activeChat, setActiveChat] = useState(null);
const [chatLoading, setChatLoading] = useState(false);
const [sending, setSending] = useState(false);
const [typedMessage, setTypedMessage] = useState('');
const unreadMessages = conversations.reduce((sum, c) => sum + c.unread, 0);
const [notifications, setNotifications] = useState([]);
const [search, setSearch] = useState('');
const unread = notifications.filter((n) => !n.read).length;
const loadNotifications = useCallback(async () => {
try {
const res = await getNotifications();
setNotifications(
(res?.data || []).map((n) => ({
id: n.id,
title: n.title,
time: n.time,
read: Boolean(n.read),
type: n.type,
icon: NOTIF_ICON[n.type] || Bell,
desc: n.desc,
stats: n.stats || [],
to: n.to,
actionText: n.actionText
}))
);
} catch {
setNotifications([]);
}
}, []);
useEffect(() => {
loadNotifications();
const t = setInterval(loadNotifications, 60000);
return () => clearInterval(t);
}, [loadNotifications]);
const loadConversations = useCallback(async () => {
try {
const res = await getConversations();
setConversations((res?.data || []).map(toConversation));
} catch {
setConversations([]);
}
}, []);
useEffect(() => {
loadConversations();
const t = setInterval(loadConversations, 60000);
return () => clearInterval(t);
}, [loadConversations]);
const markAllRead = async () => {
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
await Promise.allSettled(unreadIds.map((id) => markNotificationRead(id)));
};
const onNotifClick = async (n) => {
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
if (n.desc || n.stats) setSelectedNotif(n);
else if (n.to) navigate(n.to);
try {
await markNotificationRead(n.id);
} catch {}
};
const onMessageClick = async (conv) => {
setTypedMessage('');
setActiveChat({ id: conv.id, name: conv.name, initials: conv.initials, messages: [] });
setChatLoading(true);
try {
const res = await getConversation(conv.id);
const thread = res?.data || {};
setActiveChat({
id: conv.id,
name: thread.name || conv.name,
initials: toInitials(thread.name || conv.name),
messages: (thread.messages || thread.chat || []).map(toMessage)
});
if (conv.unread > 0) {
await markConversationRead(conv.id).catch(() => {});
setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c)));
}
} catch {
} finally {
setChatLoading(false);
}
};
const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
const handleSendMessage = async () => {
const text = typedMessage.trim();
if (!text || !activeChat || sending) return;
setSending(true);
const now = new Date();
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] }));
setTypedMessage('');
try {
await sendMessage(activeChat.id, text);
const res = await getConversation(activeChat.id);
const thread = res?.data || {};
setActiveChat((c) => c && { ...c, messages: (thread.messages || thread.chat || []).map(toMessage) });
setConversations((prev) =>
prev.map((c) => (c.id === activeChat.id ? { ...c, lastMessage: text, time: timeStr } : c))
);
} catch {
setActiveChat((c) => c && { ...c, messages: c.messages.filter((m) => !(m.sender === 'me' && m.text === text && m.time === timeStr)) });
setTypedMessage(text);
} finally {
setSending(false);
}
};
const submitSearch = (e) => {
e.preventDefault();
const q = search.trim();
if (q) navigate(`/routing?q=${encodeURIComponent(q)}`);
};
return (
<>
<TopNav
label="Main navigation"
style={{
backgroundColor: '#ffffff',
borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
}}
heading={
<TopNavHeading
logo={<Logo compact={isSidebarCollapsed} size={isSidebarCollapsed ? 36 : 32} height={28} />}
headingHref="/dashboard"
/>
}
endContent={
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: 1, justifyContent: 'flex-end' }}>
<DropdownMenu
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><MessageSquare size={20} />{unreadMessages > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unreadMessages}</span>}</div> }}
items={[
{ label: 'Messages', type: 'label' },
...conversations.map(m => ({
label: m.name,
description: m.lastMessage,
onClick: () => onMessageClick(m)
}))
]}
/>
<DropdownMenu
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><Bell size={20} />{unread > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unread}</span>}</div> }}
items={[
{ label: 'Notifications', type: 'label' },
{ label: 'Mark all read', icon: <CheckCircle2 size={16} />, onClick: markAllRead, disabled: unread === 0 },
{ type: 'divider' },
...(notifications.length === 0 ? [{ label: 'No notifications', disabled: true }] : notifications.map((n, i) => ({
label: n.title + '\u200B'.repeat(i),
description: n.time,
icon: n.icon ? (() => { const Icon = n.icon; return <Icon size={16} />; })() : undefined,
onClick: () => onNotifClick(n)
})))
]}
/>
<DropdownMenu
button={{
variant: 'ghost',
size: 'lg',
icon: <Avatar name={staffName} fallback={toInitials(staffName)} size="sm" />,
label: staffName
}}
items={[
{ label: 'Settings', icon: <Settings size={16} />, onClick: () => navigate('/hub-settings') },
{ type: 'divider' },
{ label: 'Logout', icon: <LogOut size={16} />, onClick: handleLogout }
]}
/>
</div>
}
/>
{selectedNotif && (
<dialog open style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, background: '#fff', padding: '24px', borderRadius: '8px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', maxWidth: '500px', width: '100%' }}>
<h2 style={{ margin: '0 0 16px 0', fontSize: '1.25rem' }}>{selectedNotif.title}</h2>
<p style={{ margin: '0 0 16px 0', color: '#475569' }}>{selectedNotif.desc}</p>
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
{selectedNotif.stats.map((s, i) => (
<div key={i} style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px', flex: 1 }}>
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold' }}>{s.label}</div>
<div style={{ fontSize: '1.125rem', fontWeight: 'bold', marginTop: '4px' }}>{s.value}</div>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<Button variant="ghost" onClick={() => setSelectedNotif(null)}>Dismiss</Button>
{selectedNotif.actionText && (
<Button onClick={() => { setSelectedNotif(null); navigate(selectedNotif.to); }}>
{selectedNotif.actionText} <ArrowRight size={16} />
</Button>
)}
</div>
</dialog>
)}
{activeChat && (
<dialog open style={{ position: 'fixed', bottom: '24px', right: '24px', zIndex: 1000, background: '#fff', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', width: '360px', padding: 0, overflow: 'hidden' }}>
<div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: '12px', background: '#f8fafc' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: RED, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' }}>{activeChat.initials}</div>
<h3 style={{ margin: 0, flex: 1 }}>{activeChat.name}</h3>
<Button variant="ghost" size="icon" onClick={closeChat}>×</Button>
</div>
<div style={{ height: '300px', overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', background: '#fff' }}>
{chatLoading ? (
<div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>Loading...</div>
) : activeChat.messages.length === 0 ? (
<div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>No messages yet.</div>
) : (
activeChat.messages.map((m, i) => (
<div key={i} style={{ alignSelf: m.sender === 'me' ? 'flex-end' : 'flex-start', maxWidth: '80%' }}>
<div style={{ background: m.sender === 'me' ? RED : '#f1f5f9', color: m.sender === 'me' ? '#fff' : '#0f172a', padding: '8px 12px', borderRadius: '12px', borderBottomRightRadius: m.sender === 'me' ? 0 : '12px', borderBottomLeftRadius: m.sender === 'them' ? 0 : '12px' }}>
{m.text}
</div>
<div style={{ fontSize: '0.65rem', color: '#94a3b8', textAlign: m.sender === 'me' ? 'right' : 'left', marginTop: '4px' }}>{m.time}</div>
</div>
))
)}
</div>
<div style={{ padding: '12px', borderTop: '1px solid #e2e8f0', background: '#fff', display: 'flex', gap: '8px' }}>
<TextInput style={{ flex: 1 }} value={typedMessage} onChange={(e) => setTypedMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()} placeholder="Type a message..." />
<Button size="icon" onClick={handleSendMessage} disabled={!typedMessage.trim() || sending}><Send size={16} /></Button>
</div>
</dialog>
)}
</>
);
}