313 lines
13 KiB
JavaScript
313 lines
13 KiB
JavaScript
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|